Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ebe6b6039e | ||
|
|
763a580c51 | ||
|
|
cb3e15c140 | ||
|
|
1bec37657e | ||
|
|
eb475decaf | ||
|
|
73b7dd8f51 | ||
|
|
76c3da36cd | ||
|
|
e9534034ee | ||
|
|
985d115a3f | ||
|
|
e4f3696bd8 | ||
|
|
afa4c42105 | ||
|
|
405abdfe69 | ||
|
|
874796c860 |
+12
@@ -0,0 +1,12 @@
|
|||||||
|
**/bin/
|
||||||
|
**/obj/
|
||||||
|
**/node_modules/
|
||||||
|
**/.angular/
|
||||||
|
_Published/
|
||||||
|
myoffice-publish.tar.gz
|
||||||
|
*.user
|
||||||
|
.env
|
||||||
|
env.local
|
||||||
|
MyOffice.SPA/src/environments/environment.ts
|
||||||
|
MyOffice.Shared/appsettings.shared.Development.json
|
||||||
|
MyOffice.Shared/appsettings.shared.Production.json
|
||||||
+21
@@ -0,0 +1,21 @@
|
|||||||
|
MIT License
|
||||||
|
|
||||||
|
Copyright (c) [year] [fullname]
|
||||||
|
|
||||||
|
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.
|
||||||
@@ -1,42 +0,0 @@
|
|||||||
# See http://help.github.com/ignore-files/ for more about ignoring files.
|
|
||||||
|
|
||||||
# Compiled output
|
|
||||||
/dist
|
|
||||||
/tmp
|
|
||||||
/out-tsc
|
|
||||||
/bazel-out
|
|
||||||
|
|
||||||
# Node
|
|
||||||
/node_modules
|
|
||||||
npm-debug.log
|
|
||||||
yarn-error.log
|
|
||||||
|
|
||||||
# IDEs and editors
|
|
||||||
.idea/
|
|
||||||
.project
|
|
||||||
.classpath
|
|
||||||
.c9/
|
|
||||||
*.launch
|
|
||||||
.settings/
|
|
||||||
*.sublime-workspace
|
|
||||||
|
|
||||||
# Visual Studio Code
|
|
||||||
.vscode/*
|
|
||||||
!.vscode/settings.json
|
|
||||||
!.vscode/tasks.json
|
|
||||||
!.vscode/launch.json
|
|
||||||
!.vscode/extensions.json
|
|
||||||
.history/*
|
|
||||||
|
|
||||||
# Miscellaneous
|
|
||||||
/.angular/cache
|
|
||||||
.sass-cache/
|
|
||||||
/connect.lock
|
|
||||||
/coverage
|
|
||||||
/libpeerconnection.log
|
|
||||||
testem.log
|
|
||||||
/typings
|
|
||||||
|
|
||||||
# System files
|
|
||||||
.DS_Store
|
|
||||||
Thumbs.db
|
|
||||||
@@ -1,45 +0,0 @@
|
|||||||
// Karma configuration file, see link for more information
|
|
||||||
// https://karma-runner.github.io/1.0/config/configuration-file.html
|
|
||||||
|
|
||||||
module.exports = function(config) {
|
|
||||||
config.set({
|
|
||||||
basePath: '',
|
|
||||||
frameworks: ['jasmine', '@angular-devkit/build-angular'],
|
|
||||||
plugins: [
|
|
||||||
require('karma-jasmine'),
|
|
||||||
require('karma-chrome-launcher'),
|
|
||||||
require('karma-jasmine-html-reporter'),
|
|
||||||
require('karma-coverage'),
|
|
||||||
require('@angular-devkit/build-angular/plugins/karma')
|
|
||||||
],
|
|
||||||
client: {
|
|
||||||
jasmine: {
|
|
||||||
// you can add configuration options for Jasmine here
|
|
||||||
// the possible options are listed at https://jasmine.github.io/api/edge/Configuration.html
|
|
||||||
// for example, you can disable the random execution with `random: false`
|
|
||||||
// or set a specific seed with `seed: 4321`
|
|
||||||
|
|
||||||
},
|
|
||||||
clearContext: false // leave Jasmine Spec Runner output visible in browser
|
|
||||||
},
|
|
||||||
jasmineHtmlReporter: {
|
|
||||||
suppressAll: true // removes the duplicated traces
|
|
||||||
},
|
|
||||||
coverageReporter: {
|
|
||||||
dir: require('path').join(__dirname, './coverage/angular-template'),
|
|
||||||
subdir: '.',
|
|
||||||
reporters: [
|
|
||||||
{ type: 'html' },
|
|
||||||
{ type: 'text-summary' }
|
|
||||||
]
|
|
||||||
},
|
|
||||||
reporters: ['progress', 'kjhtml'],
|
|
||||||
port: 9876,
|
|
||||||
colors: true,
|
|
||||||
logLevel: config.LOG_INFO,
|
|
||||||
autoWatch: true,
|
|
||||||
browsers: ['Chrome'],
|
|
||||||
singleRun: false,
|
|
||||||
restartOnFileChange: true
|
|
||||||
});
|
|
||||||
};
|
|
||||||
@@ -67,7 +67,7 @@
|
|||||||
"karma-coverage": "~2.2.0",
|
"karma-coverage": "~2.2.0",
|
||||||
"karma-jasmine": "~5.1.0",
|
"karma-jasmine": "~5.1.0",
|
||||||
"karma-jasmine-html-reporter": "~2.0.0",
|
"karma-jasmine-html-reporter": "~2.0.0",
|
||||||
"typescript": "^5.5.4"
|
"typescript": "~5.5.4"
|
||||||
},
|
},
|
||||||
"overrides": {
|
"overrides": {
|
||||||
"@auth0/auth0-spa-js": "2.1.3"
|
"@auth0/auth0-spa-js": "2.1.3"
|
||||||
|
|||||||
@@ -1,39 +0,0 @@
|
|||||||
import { TestBed } from '@angular/core/testing';
|
|
||||||
import { RouterTestingModule } from '@angular/router/testing';
|
|
||||||
import { AppComponent } from './app.component';
|
|
||||||
|
|
||||||
describe('AppComponent',
|
|
||||||
() => {
|
|
||||||
beforeEach(async () => {
|
|
||||||
await TestBed.configureTestingModule({
|
|
||||||
imports: [
|
|
||||||
RouterTestingModule
|
|
||||||
],
|
|
||||||
declarations: [
|
|
||||||
AppComponent
|
|
||||||
],
|
|
||||||
}).compileComponents();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should create the app',
|
|
||||||
() => {
|
|
||||||
const fixture = TestBed.createComponent(AppComponent);
|
|
||||||
const app = fixture.componentInstance;
|
|
||||||
expect(app).toBeTruthy();
|
|
||||||
});
|
|
||||||
|
|
||||||
it(`should have as title 'spire'`,
|
|
||||||
() => {
|
|
||||||
const fixture = TestBed.createComponent(AppComponent);
|
|
||||||
const app = fixture.componentInstance;
|
|
||||||
expect(app.title).toEqual('spire');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should render title',
|
|
||||||
() => {
|
|
||||||
const fixture = TestBed.createComponent(AppComponent);
|
|
||||||
fixture.detectChanges();
|
|
||||||
const compiled = fixture.nativeElement as HTMLElement;
|
|
||||||
expect(compiled.querySelector('.content span')?.textContent).toContain('spire app is running!');
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -9,19 +9,39 @@ import { TranslateHttpLoader } from '@ngx-translate/http-loader';
|
|||||||
import { LoadingBarRouterModule } from '@ngx-loading-bar/router';
|
import { LoadingBarRouterModule } from '@ngx-loading-bar/router';
|
||||||
import { LoadingBarHttpClientModule } from '@ngx-loading-bar/http-client';
|
import { LoadingBarHttpClientModule } from '@ngx-loading-bar/http-client';
|
||||||
import { NgScrollbarModule } from 'ngx-scrollbar';
|
import { NgScrollbarModule } from 'ngx-scrollbar';
|
||||||
|
import { SocialLoginModule } from '@abacritt/angularx-social-login';
|
||||||
|
|
||||||
// app
|
// app
|
||||||
import { CoreModule } from './core/core.module';
|
import { CoreModule } from './core/core.module';
|
||||||
import { AppRoutingModule } from './app-routing.module';
|
import { AppRoutingModule } from './app-routing.module';
|
||||||
import { ErrorInterceptor } from './core/interceptor/error.interceptor';
|
import { ErrorInterceptor } from './core/interceptor/error.interceptor';
|
||||||
import { UpdateDateHttpInterceptor } from './core/interceptor/update.date.http.interceptor ';
|
import { UpdateDateHttpInterceptor } from './core/interceptor/update.date.http.interceptor ';
|
||||||
|
import { ExternalLoginConfig } from './config.external-login';
|
||||||
|
import { environment } from '../environments/environment';
|
||||||
|
|
||||||
export function createTranslateLoader(http: HttpClient) {
|
export function createTranslateLoader(http: HttpClient) {
|
||||||
return new TranslateHttpLoader(http, 'assets/i18n/', '.json');
|
return new TranslateHttpLoader(http, 'assets/i18n/', '.json');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const socialProviders = environment.externalLogins?.google?.clientId
|
||||||
|
? [
|
||||||
|
importProvidersFrom(SocialLoginModule.initialize(ExternalLoginConfig.getSocialConfig())),
|
||||||
|
{
|
||||||
|
provide: 'SocialAuthServiceConfig',
|
||||||
|
useValue: ExternalLoginConfig.getSocialConfig(),
|
||||||
|
},
|
||||||
|
]
|
||||||
|
: [
|
||||||
|
// Empty config so providedIn:'root' SocialAuthService (if ever constructed) does not crash.
|
||||||
|
{
|
||||||
|
provide: 'SocialAuthServiceConfig',
|
||||||
|
useValue: ExternalLoginConfig.getSocialConfig(),
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
export const appConfig: ApplicationConfig = {
|
export const appConfig: ApplicationConfig = {
|
||||||
providers: [
|
providers: [
|
||||||
|
...socialProviders,
|
||||||
importProvidersFrom(
|
importProvidersFrom(
|
||||||
BrowserAnimationsModule,
|
BrowserAnimationsModule,
|
||||||
AppRoutingModule,
|
AppRoutingModule,
|
||||||
@@ -35,11 +55,9 @@ export const appConfig: ApplicationConfig = {
|
|||||||
deps: [HttpClient],
|
deps: [HttpClient],
|
||||||
},
|
},
|
||||||
}),
|
}),
|
||||||
// core & shared
|
|
||||||
CoreModule,
|
CoreModule,
|
||||||
),
|
),
|
||||||
{ provide: LocationStrategy, useClass: HashLocationStrategy },
|
{ provide: LocationStrategy, useClass: HashLocationStrategy },
|
||||||
// Bearer tokens: angular-oauth2-oidc resourceServer (AuthModuleConfig.sendAccessToken)
|
|
||||||
{ provide: HTTP_INTERCEPTORS, useClass: ErrorInterceptor, multi: true },
|
{ provide: HTTP_INTERCEPTORS, useClass: ErrorInterceptor, multi: true },
|
||||||
{ provide: HTTP_INTERCEPTORS, useClass: UpdateDateHttpInterceptor, multi: true },
|
{ provide: HTTP_INTERCEPTORS, useClass: UpdateDateHttpInterceptor, multi: true },
|
||||||
provideHttpClient(withInterceptorsFromDi()),
|
provideHttpClient(withInterceptorsFromDi()),
|
||||||
|
|||||||
@@ -19,6 +19,11 @@ import { SigninComponent } from './signin/signin.component';
|
|||||||
import { SignupComponent } from './signup/signup.component';
|
import { SignupComponent } from './signup/signup.component';
|
||||||
import { LockedComponent } from './locked/locked.component';
|
import { LockedComponent } from './locked/locked.component';
|
||||||
import { ForgotPasswordComponent } from './forgot-password/forgot-password.component';
|
import { ForgotPasswordComponent } from './forgot-password/forgot-password.component';
|
||||||
|
import { environment } from '../../environments/environment';
|
||||||
|
|
||||||
|
const googleButtonImports = environment.externalLogins?.google?.clientId
|
||||||
|
? [GoogleSigninButtonModule]
|
||||||
|
: [];
|
||||||
|
|
||||||
@NgModule({
|
@NgModule({
|
||||||
imports: [
|
imports: [
|
||||||
@@ -30,7 +35,7 @@ import { ForgotPasswordComponent } from './forgot-password/forgot-password.compo
|
|||||||
MatInputModule,
|
MatInputModule,
|
||||||
MatIconModule,
|
MatIconModule,
|
||||||
MatButtonModule,
|
MatButtonModule,
|
||||||
GoogleSigninButtonModule,
|
...googleButtonImports,
|
||||||
],
|
],
|
||||||
declarations: [
|
declarations: [
|
||||||
Page500Component,
|
Page500Component,
|
||||||
|
|||||||
@@ -1,23 +0,0 @@
|
|||||||
import { ComponentFixture, TestBed, waitForAsync } from '@angular/core/testing';
|
|
||||||
import { ForgotPasswordComponent } from './forgot-password.component';
|
|
||||||
describe('ForgotPasswordComponent',
|
|
||||||
() => {
|
|
||||||
let component: ForgotPasswordComponent;
|
|
||||||
let fixture: ComponentFixture<ForgotPasswordComponent>;
|
|
||||||
beforeEach(
|
|
||||||
waitForAsync(() => {
|
|
||||||
TestBed.configureTestingModule({
|
|
||||||
declarations: [ForgotPasswordComponent],
|
|
||||||
}).compileComponents();
|
|
||||||
})
|
|
||||||
);
|
|
||||||
beforeEach(() => {
|
|
||||||
fixture = TestBed.createComponent(ForgotPasswordComponent);
|
|
||||||
component = fixture.componentInstance;
|
|
||||||
fixture.detectChanges();
|
|
||||||
});
|
|
||||||
it('should create',
|
|
||||||
() => {
|
|
||||||
expect(component).toBeTruthy();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,23 +0,0 @@
|
|||||||
import { ComponentFixture, TestBed, waitForAsync } from '@angular/core/testing';
|
|
||||||
import { LockedComponent } from './locked.component';
|
|
||||||
describe('LockedComponent',
|
|
||||||
() => {
|
|
||||||
let component: LockedComponent;
|
|
||||||
let fixture: ComponentFixture<LockedComponent>;
|
|
||||||
beforeEach(
|
|
||||||
waitForAsync(() => {
|
|
||||||
TestBed.configureTestingModule({
|
|
||||||
declarations: [LockedComponent],
|
|
||||||
}).compileComponents();
|
|
||||||
})
|
|
||||||
);
|
|
||||||
beforeEach(() => {
|
|
||||||
fixture = TestBed.createComponent(LockedComponent);
|
|
||||||
component = fixture.componentInstance;
|
|
||||||
fixture.detectChanges();
|
|
||||||
});
|
|
||||||
it('should create',
|
|
||||||
() => {
|
|
||||||
expect(component).toBeTruthy();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,24 +0,0 @@
|
|||||||
import { ComponentFixture, TestBed, waitForAsync } from '@angular/core/testing';
|
|
||||||
import { Page404Component } from './page404.component';
|
|
||||||
|
|
||||||
describe('Page404Component',
|
|
||||||
() => {
|
|
||||||
let component: Page404Component;
|
|
||||||
let fixture: ComponentFixture<Page404Component>;
|
|
||||||
beforeEach(
|
|
||||||
waitForAsync(() => {
|
|
||||||
TestBed.configureTestingModule({
|
|
||||||
declarations: [Page404Component],
|
|
||||||
}).compileComponents();
|
|
||||||
})
|
|
||||||
);
|
|
||||||
beforeEach(() => {
|
|
||||||
fixture = TestBed.createComponent(Page404Component);
|
|
||||||
component = fixture.componentInstance;
|
|
||||||
fixture.detectChanges();
|
|
||||||
});
|
|
||||||
it('should create',
|
|
||||||
() => {
|
|
||||||
expect(component).toBeTruthy();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,24 +0,0 @@
|
|||||||
import { ComponentFixture, TestBed, waitForAsync } from '@angular/core/testing';
|
|
||||||
import { Page500Component } from './page500.component';
|
|
||||||
|
|
||||||
describe('Page500Component',
|
|
||||||
() => {
|
|
||||||
let component: Page500Component;
|
|
||||||
let fixture: ComponentFixture<Page500Component>;
|
|
||||||
beforeEach(
|
|
||||||
waitForAsync(() => {
|
|
||||||
TestBed.configureTestingModule({
|
|
||||||
declarations: [Page500Component],
|
|
||||||
}).compileComponents();
|
|
||||||
})
|
|
||||||
);
|
|
||||||
beforeEach(() => {
|
|
||||||
fixture = TestBed.createComponent(Page500Component);
|
|
||||||
component = fixture.componentInstance;
|
|
||||||
fixture.detectChanges();
|
|
||||||
});
|
|
||||||
it('should create',
|
|
||||||
() => {
|
|
||||||
expect(component).toBeTruthy();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -87,7 +87,7 @@
|
|||||||
<i class="fab fa-google"></i>
|
<i class="fab fa-google"></i>
|
||||||
</a>
|
</a>
|
||||||
-->
|
-->
|
||||||
<asl-google-signin-button type="icon" size="medium" width="200" shape="rectangular" theme="filled_black" logo_alignment="center" locale=""></asl-google-signin-button>
|
<asl-google-signin-button type="icon" size="medium" [width]="200" shape="rectangular" theme="filled_black" logo_alignment="center" locale=""></asl-google-signin-button>
|
||||||
</li>
|
</li>
|
||||||
<!--
|
<!--
|
||||||
<li class="list-inline-item">
|
<li class="list-inline-item">
|
||||||
|
|||||||
@@ -1,24 +0,0 @@
|
|||||||
import { ComponentFixture, TestBed, waitForAsync } from '@angular/core/testing';
|
|
||||||
import { SigninComponent } from './signin.component';
|
|
||||||
|
|
||||||
describe('SigninComponent',
|
|
||||||
() => {
|
|
||||||
let component: SigninComponent;
|
|
||||||
let fixture: ComponentFixture<SigninComponent>;
|
|
||||||
beforeEach(
|
|
||||||
waitForAsync(() => {
|
|
||||||
TestBed.configureTestingModule({
|
|
||||||
declarations: [SigninComponent],
|
|
||||||
}).compileComponents();
|
|
||||||
})
|
|
||||||
);
|
|
||||||
beforeEach(() => {
|
|
||||||
fixture = TestBed.createComponent(SigninComponent);
|
|
||||||
component = fixture.componentInstance;
|
|
||||||
fixture.detectChanges();
|
|
||||||
});
|
|
||||||
it('should create',
|
|
||||||
() => {
|
|
||||||
expect(component).toBeTruthy();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -3,9 +3,6 @@ import { Component, OnInit } from '@angular/core';
|
|||||||
import { Router, ActivatedRoute } from '@angular/router';
|
import { Router, ActivatedRoute } from '@angular/router';
|
||||||
import { FormBuilder, FormControl, FormGroup, Validators } from '@angular/forms';
|
import { FormBuilder, FormControl, FormGroup, Validators } from '@angular/forms';
|
||||||
|
|
||||||
// libs
|
|
||||||
import { SocialAuthService } from '@abacritt/angularx-social-login';
|
|
||||||
|
|
||||||
// app
|
// app
|
||||||
import { AuthService } from 'src/app/core/service/auth.service';
|
import { AuthService } from 'src/app/core/service/auth.service';
|
||||||
import { UnsubscribeOnDestroyAdapter } from 'src/app/shared/UnsubscribeOnDestroyAdapter';
|
import { UnsubscribeOnDestroyAdapter } from 'src/app/shared/UnsubscribeOnDestroyAdapter';
|
||||||
|
|||||||
@@ -1,24 +0,0 @@
|
|||||||
import { ComponentFixture, TestBed, waitForAsync } from '@angular/core/testing';
|
|
||||||
import { SignupComponent } from './signup.component';
|
|
||||||
|
|
||||||
describe('SignupComponent',
|
|
||||||
() => {
|
|
||||||
let component: SignupComponent;
|
|
||||||
let fixture: ComponentFixture<SignupComponent>;
|
|
||||||
beforeEach(
|
|
||||||
waitForAsync(() => {
|
|
||||||
TestBed.configureTestingModule({
|
|
||||||
declarations: [SignupComponent],
|
|
||||||
}).compileComponents();
|
|
||||||
})
|
|
||||||
);
|
|
||||||
beforeEach(() => {
|
|
||||||
fixture = TestBed.createComponent(SignupComponent);
|
|
||||||
component = fixture.componentInstance;
|
|
||||||
fixture.detectChanges();
|
|
||||||
});
|
|
||||||
it('should create',
|
|
||||||
() => {
|
|
||||||
expect(component).toBeTruthy();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -58,9 +58,10 @@ export class ExternalLoginConfig {
|
|||||||
}
|
}
|
||||||
|
|
||||||
static getAuth0Config() {
|
static getAuth0Config() {
|
||||||
|
const auth0 = environment.externalLogins?.auth0;
|
||||||
return {
|
return {
|
||||||
clientId: environment.externalLogins.auth0.clientId,
|
clientId: auth0?.clientId || 'unused',
|
||||||
domain: environment.externalLogins.auth0.domain,
|
domain: auth0?.domain || 'unused.auth0.com',
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -23,7 +23,8 @@ function resolveAllowedUrls(): string[] {
|
|||||||
}
|
}
|
||||||
const origin = publicOrigin();
|
const origin = publicOrigin();
|
||||||
const api = (environment.apiUrl ?? '').trim().replace(/\/$/, '');
|
const api = (environment.apiUrl ?? '').trim().replace(/\/$/, '');
|
||||||
return api ? [origin, api] : [origin];
|
// Relative ApiRoutes (`/api/...`) need '/api' in allowedUrls or the Bearer is never sent.
|
||||||
|
return api ? [origin, api, '/api'] : [origin, '/api'];
|
||||||
}
|
}
|
||||||
|
|
||||||
export const AuthCodeFlowConfig: AuthConfig = {
|
export const AuthCodeFlowConfig: AuthConfig = {
|
||||||
|
|||||||
@@ -7,10 +7,9 @@ import { APP_INITIALIZER } from '@angular/core';
|
|||||||
import { CommonModule } from '@angular/common';
|
import { CommonModule } from '@angular/common';
|
||||||
|
|
||||||
// libs
|
// libs
|
||||||
|
import { AuthModule } from '@auth0/auth0-angular';
|
||||||
import { OAuthModule, OAuthModuleConfig, OAuthStorage } from 'angular-oauth2-oidc';
|
import { OAuthModule, OAuthModuleConfig, OAuthStorage } from 'angular-oauth2-oidc';
|
||||||
import { AuthConfig } from 'angular-oauth2-oidc';
|
import { AuthConfig } from 'angular-oauth2-oidc';
|
||||||
import { AuthModule } from '@auth0/auth0-angular';
|
|
||||||
import { SocialLoginModule } from '@abacritt/angularx-social-login';
|
|
||||||
|
|
||||||
// app
|
// app
|
||||||
import { AuthGuard } from './guard/auth.guard';
|
import { AuthGuard } from './guard/auth.guard';
|
||||||
@@ -33,7 +32,6 @@ export function storageFactory(): OAuthStorage {
|
|||||||
declarations: [],
|
declarations: [],
|
||||||
imports: [
|
imports: [
|
||||||
CommonModule,
|
CommonModule,
|
||||||
SocialLoginModule,
|
|
||||||
OAuthModule.forRoot(),
|
OAuthModule.forRoot(),
|
||||||
AuthModule.forRoot(ExternalLoginConfig.getAuth0Config()),
|
AuthModule.forRoot(ExternalLoginConfig.getAuth0Config()),
|
||||||
],
|
],
|
||||||
@@ -42,7 +40,6 @@ export function storageFactory(): OAuthStorage {
|
|||||||
{ provide: AuthConfig, useValue: AuthCodeFlowConfig },
|
{ provide: AuthConfig, useValue: AuthCodeFlowConfig },
|
||||||
{ provide: OAuthModuleConfig, useValue: AuthModuleConfig },
|
{ provide: OAuthModuleConfig, useValue: AuthModuleConfig },
|
||||||
{ provide: OAuthStorage, useFactory: storageFactory },
|
{ provide: OAuthStorage, useFactory: storageFactory },
|
||||||
{ provide: 'SocialAuthServiceConfig', useValue: ExternalLoginConfig.getSocialConfig() },
|
|
||||||
RightSidebarService,
|
RightSidebarService,
|
||||||
AuthGuard,
|
AuthGuard,
|
||||||
AuthService,
|
AuthService,
|
||||||
|
|||||||
@@ -1,7 +0,0 @@
|
|||||||
import { SelectableModel } from './selectable.model';
|
|
||||||
|
|
||||||
describe('SelectableModel', () => {
|
|
||||||
it('should create an instance', () => {
|
|
||||||
expect(new SelectableModel()).toBeTruthy();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,18 +0,0 @@
|
|||||||
import { TestBed } from '@angular/core/testing';
|
|
||||||
|
|
||||||
import { AuthService } from './auth.service';
|
|
||||||
|
|
||||||
describe('AuthService',
|
|
||||||
() => {
|
|
||||||
let service: AuthService;
|
|
||||||
|
|
||||||
beforeEach(() => {
|
|
||||||
TestBed.configureTestingModule({});
|
|
||||||
service = TestBed.inject(AuthService);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should be created',
|
|
||||||
() => {
|
|
||||||
expect(service).toBeTruthy();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
// angular
|
// angular
|
||||||
import { Injectable } from '@angular/core';
|
import { Injectable, Injector } from '@angular/core';
|
||||||
import { HttpClient } from '@angular/common/http';
|
import { HttpClient } from '@angular/common/http';
|
||||||
import { Router } from '@angular/router';
|
import { Router } from '@angular/router';
|
||||||
import { ActivatedRoute } from '@angular/router';
|
import { ActivatedRoute } from '@angular/router';
|
||||||
@@ -28,6 +28,7 @@ import { UserProfileModel } from '../models/user-profile.model';
|
|||||||
import { GeneralResultModel } from '../models/general-result.model';
|
import { GeneralResultModel } from '../models/general-result.model';
|
||||||
import { getSafeRedirectUrl } from '../utils/safe-redirect';
|
import { getSafeRedirectUrl } from '../utils/safe-redirect';
|
||||||
import { SubjectExtensions } from '../extensions/general.extensions';
|
import { SubjectExtensions } from '../extensions/general.extensions';
|
||||||
|
import { environment } from '../../../environments/environment';
|
||||||
|
|
||||||
export function authAppInitializerFactory(authService: AuthService): () => Promise<void> {
|
export function authAppInitializerFactory(authService: AuthService): () => Promise<void> {
|
||||||
if (window.location.href.indexOf('/silent-refresh.html') !== -1) {
|
if (window.location.href.indexOf('/silent-refresh.html') !== -1) {
|
||||||
@@ -52,17 +53,21 @@ export class AuthService {
|
|||||||
private route: ActivatedRoute,
|
private route: ActivatedRoute,
|
||||||
private oidcHelperService: OidcHelperService,
|
private oidcHelperService: OidcHelperService,
|
||||||
private auth0Service: Auth0Service,
|
private auth0Service: Auth0Service,
|
||||||
private externalAuthService: SocialAuthService,
|
private injector: Injector,
|
||||||
) {
|
) {
|
||||||
this.currentUserSubject = new BehaviorSubject<UserModel>({} as UserModel);
|
this.currentUserSubject = new BehaviorSubject<UserModel>({} as UserModel);
|
||||||
this.currentUser$ = this.currentUserSubject.asObservable();
|
this.currentUser$ = this.currentUserSubject.asObservable();
|
||||||
|
|
||||||
// on external social login
|
// Lazy: SocialAuthService is providedIn:'root' and needs SocialAuthServiceConfig.
|
||||||
this.externalAuthService.authState.subscribe((user) => {
|
// Avoid constructor injection so password login works when Google is not configured.
|
||||||
if (user?.idToken) {
|
if (environment.externalLogins?.google?.clientId) {
|
||||||
oidcHelperService.loginByExternalLogin('google', user.idToken);
|
const social = this.injector.get(SocialAuthService, null);
|
||||||
}
|
social?.authState.subscribe((user) => {
|
||||||
});
|
if (user?.idToken) {
|
||||||
|
oidcHelperService.loginByExternalLogin('google', user.idToken);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
this.oidcHelperService.isDoneLoading$
|
this.oidcHelperService.isDoneLoading$
|
||||||
.pipe(
|
.pipe(
|
||||||
@@ -155,19 +160,15 @@ export class AuthService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
loginGoogle(redirectUrl?: string): Observable<any> {
|
loginGoogle(redirectUrl?: string): Observable<any> {
|
||||||
return from(this.externalAuthService.signIn(GoogleLoginProvider.PROVIDER_ID));
|
const social = this.injector.get(SocialAuthService, null);
|
||||||
|
if (!social) {
|
||||||
|
return throwError(() => new Error('Google login is not configured'));
|
||||||
|
}
|
||||||
|
return from(social.signIn(GoogleLoginProvider.PROVIDER_ID));
|
||||||
}
|
}
|
||||||
|
|
||||||
attachGoogle(): Observable<any> {
|
attachGoogle(): Observable<any> {
|
||||||
var result = new Subject();
|
return new Subject();
|
||||||
|
|
||||||
/*this.externalAuthService.signIn(GoogleLoginProvider.PROVIDER_ID).then(function (resp) {
|
|
||||||
//console.log('attachGoogle', resp);
|
|
||||||
//return result.next(resp);
|
|
||||||
//this.attach(idToken.__raw, result);
|
|
||||||
});*/
|
|
||||||
|
|
||||||
return result;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
loginAuth0(): Observable<GeneralResultModel> {
|
loginAuth0(): Observable<GeneralResultModel> {
|
||||||
@@ -182,14 +183,12 @@ export class AuthService {
|
|||||||
}
|
}
|
||||||
subject.next({ success: login.success, error: login.error });
|
subject.next({ success: login.success, error: login.error });
|
||||||
});
|
});
|
||||||
} else {
|
|
||||||
//subject.next({ success: false, error: { description: 'Login failed' } });
|
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
this.auth0Service.getAccessTokenWithPopup().subscribe(popupToken => {
|
this.auth0Service.getAccessTokenWithPopup().subscribe(popupToken => {
|
||||||
if (!popupToken) {
|
if (!popupToken) {
|
||||||
this.auth0Service.getAccessTokenSilently().subscribe(silently => {
|
this.auth0Service.getAccessTokenSilently().subscribe(() => {
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
@@ -203,7 +202,7 @@ export class AuthService {
|
|||||||
subs.unsubscribe();
|
subs.unsubscribe();
|
||||||
|
|
||||||
this.attach(token?.__raw).subscribe(
|
this.attach(token?.__raw).subscribe(
|
||||||
(resp) => subject.next({ success: true, data: token?.__raw }),
|
() => subject.next({ success: true, data: token?.__raw }),
|
||||||
(err) => subject.next({ success: false, data: err })
|
(err) => subject.next({ success: false, data: err })
|
||||||
);
|
);
|
||||||
} else {
|
} else {
|
||||||
@@ -213,7 +212,7 @@ export class AuthService {
|
|||||||
|
|
||||||
this.auth0Service.getAccessTokenWithPopup().subscribe(popupToken => {
|
this.auth0Service.getAccessTokenWithPopup().subscribe(popupToken => {
|
||||||
if (!popupToken) {
|
if (!popupToken) {
|
||||||
this.auth0Service.getAccessTokenSilently().subscribe(silently => {
|
this.auth0Service.getAccessTokenSilently().subscribe(() => {
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,22 +0,0 @@
|
|||||||
import { getSafeRedirectUrl } from './safe-redirect';
|
|
||||||
|
|
||||||
describe('getSafeRedirectUrl', () => {
|
|
||||||
it('returns fallback when raw is empty', () => {
|
|
||||||
expect(getSafeRedirectUrl(null)).toBe('/dashboard/rests');
|
|
||||||
expect(getSafeRedirectUrl(undefined)).toBe('/dashboard/rests');
|
|
||||||
expect(getSafeRedirectUrl('')).toBe('/dashboard/rests');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('allows relative in-app paths', () => {
|
|
||||||
expect(getSafeRedirectUrl('/accounts')).toBe('/accounts');
|
|
||||||
expect(getSafeRedirectUrl('%2Fdashboard%2Frests')).toBe('/dashboard/rests');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('blocks open redirects', () => {
|
|
||||||
expect(getSafeRedirectUrl('//evil.com')).toBe('/dashboard/rests');
|
|
||||||
expect(getSafeRedirectUrl('https://evil.com')).toBe('/dashboard/rests');
|
|
||||||
expect(getSafeRedirectUrl('http://evil.com')).toBe('/dashboard/rests');
|
|
||||||
expect(getSafeRedirectUrl('/path?next=https://evil.com')).toBe('/dashboard/rests');
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
@@ -1,27 +0,0 @@
|
|||||||
import { ComponentFixture, TestBed } from '@angular/core/testing';
|
|
||||||
|
|
||||||
import { Dashboard2Component } from './dashboard2.component';
|
|
||||||
|
|
||||||
describe('Dashboard2Component',
|
|
||||||
() => {
|
|
||||||
let component: Dashboard2Component;
|
|
||||||
let fixture: ComponentFixture<Dashboard2Component>;
|
|
||||||
|
|
||||||
beforeEach(async () => {
|
|
||||||
await TestBed.configureTestingModule({
|
|
||||||
declarations: [Dashboard2Component]
|
|
||||||
})
|
|
||||||
.compileComponents();
|
|
||||||
});
|
|
||||||
|
|
||||||
beforeEach(() => {
|
|
||||||
fixture = TestBed.createComponent(Dashboard2Component);
|
|
||||||
component = fixture.componentInstance;
|
|
||||||
fixture.detectChanges();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should create',
|
|
||||||
() => {
|
|
||||||
expect(component).toBeTruthy();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,27 +0,0 @@
|
|||||||
import { ComponentFixture, TestBed } from '@angular/core/testing';
|
|
||||||
|
|
||||||
import { Dashboard2Component } from './dashboard2.component';
|
|
||||||
|
|
||||||
describe('Dashboard2Component',
|
|
||||||
() => {
|
|
||||||
let component: Dashboard2Component;
|
|
||||||
let fixture: ComponentFixture<Dashboard2Component>;
|
|
||||||
|
|
||||||
beforeEach(async () => {
|
|
||||||
await TestBed.configureTestingModule({
|
|
||||||
declarations: [Dashboard2Component]
|
|
||||||
})
|
|
||||||
.compileComponents();
|
|
||||||
});
|
|
||||||
|
|
||||||
beforeEach(() => {
|
|
||||||
fixture = TestBed.createComponent(Dashboard2Component);
|
|
||||||
component = fixture.componentInstance;
|
|
||||||
fixture.detectChanges();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should create',
|
|
||||||
() => {
|
|
||||||
expect(component).toBeTruthy();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,27 +0,0 @@
|
|||||||
import { ComponentFixture, TestBed } from '@angular/core/testing';
|
|
||||||
|
|
||||||
import { Dashboard2Component } from './dashboard2.component';
|
|
||||||
|
|
||||||
describe('Dashboard2Component',
|
|
||||||
() => {
|
|
||||||
let component: Dashboard2Component;
|
|
||||||
let fixture: ComponentFixture<Dashboard2Component>;
|
|
||||||
|
|
||||||
beforeEach(async () => {
|
|
||||||
await TestBed.configureTestingModule({
|
|
||||||
declarations: [Dashboard2Component]
|
|
||||||
})
|
|
||||||
.compileComponents();
|
|
||||||
});
|
|
||||||
|
|
||||||
beforeEach(() => {
|
|
||||||
fixture = TestBed.createComponent(Dashboard2Component);
|
|
||||||
component = fixture.componentInstance;
|
|
||||||
fixture.detectChanges();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should create',
|
|
||||||
() => {
|
|
||||||
expect(component).toBeTruthy();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,23 +0,0 @@
|
|||||||
import { ComponentFixture, TestBed, waitForAsync } from '@angular/core/testing';
|
|
||||||
import { HeaderComponent } from './header.component';
|
|
||||||
describe('HeaderComponent',
|
|
||||||
() => {
|
|
||||||
let component: HeaderComponent;
|
|
||||||
let fixture: ComponentFixture<HeaderComponent>;
|
|
||||||
beforeEach(
|
|
||||||
waitForAsync(() => {
|
|
||||||
TestBed.configureTestingModule({
|
|
||||||
declarations: [HeaderComponent],
|
|
||||||
}).compileComponents();
|
|
||||||
})
|
|
||||||
);
|
|
||||||
beforeEach(() => {
|
|
||||||
fixture = TestBed.createComponent(HeaderComponent);
|
|
||||||
component = fixture.componentInstance;
|
|
||||||
fixture.detectChanges();
|
|
||||||
});
|
|
||||||
it('should create',
|
|
||||||
() => {
|
|
||||||
expect(component).toBeTruthy();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,23 +0,0 @@
|
|||||||
import { ComponentFixture, TestBed, waitForAsync } from '@angular/core/testing';
|
|
||||||
import { PageLoaderComponent } from './page-loader.component';
|
|
||||||
describe('PageLoaderComponent',
|
|
||||||
() => {
|
|
||||||
let component: PageLoaderComponent;
|
|
||||||
let fixture: ComponentFixture<PageLoaderComponent>;
|
|
||||||
beforeEach(
|
|
||||||
waitForAsync(() => {
|
|
||||||
TestBed.configureTestingModule({
|
|
||||||
declarations: [PageLoaderComponent],
|
|
||||||
}).compileComponents();
|
|
||||||
})
|
|
||||||
);
|
|
||||||
beforeEach(() => {
|
|
||||||
fixture = TestBed.createComponent(PageLoaderComponent);
|
|
||||||
component = fixture.componentInstance;
|
|
||||||
fixture.detectChanges();
|
|
||||||
});
|
|
||||||
it('should create',
|
|
||||||
() => {
|
|
||||||
expect(component).toBeTruthy();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,23 +0,0 @@
|
|||||||
import { ComponentFixture, TestBed, waitForAsync } from '@angular/core/testing';
|
|
||||||
import { RightSidebarComponent } from './right-sidebar.component';
|
|
||||||
describe('RightSidebarComponent',
|
|
||||||
() => {
|
|
||||||
let component: RightSidebarComponent;
|
|
||||||
let fixture: ComponentFixture<RightSidebarComponent>;
|
|
||||||
beforeEach(
|
|
||||||
waitForAsync(() => {
|
|
||||||
TestBed.configureTestingModule({
|
|
||||||
declarations: [RightSidebarComponent],
|
|
||||||
}).compileComponents();
|
|
||||||
})
|
|
||||||
);
|
|
||||||
beforeEach(() => {
|
|
||||||
fixture = TestBed.createComponent(RightSidebarComponent);
|
|
||||||
component = fixture.componentInstance;
|
|
||||||
fixture.detectChanges();
|
|
||||||
});
|
|
||||||
it('should create',
|
|
||||||
() => {
|
|
||||||
expect(component).toBeTruthy();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,23 +0,0 @@
|
|||||||
import { ComponentFixture, TestBed, waitForAsync } from '@angular/core/testing';
|
|
||||||
import { SidebarComponent } from './sidebar.component';
|
|
||||||
describe('SidebarComponent',
|
|
||||||
() => {
|
|
||||||
let component: SidebarComponent;
|
|
||||||
let fixture: ComponentFixture<SidebarComponent>;
|
|
||||||
beforeEach(
|
|
||||||
waitForAsync(() => {
|
|
||||||
TestBed.configureTestingModule({
|
|
||||||
declarations: [SidebarComponent],
|
|
||||||
}).compileComponents();
|
|
||||||
})
|
|
||||||
);
|
|
||||||
beforeEach(() => {
|
|
||||||
fixture = TestBed.createComponent(SidebarComponent);
|
|
||||||
component = fixture.componentInstance;
|
|
||||||
fixture.detectChanges();
|
|
||||||
});
|
|
||||||
it('should create',
|
|
||||||
() => {
|
|
||||||
expect(component).toBeTruthy();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,24 +0,0 @@
|
|||||||
import { ComponentFixture, TestBed, waitForAsync } from '@angular/core/testing';
|
|
||||||
import { UserProfileComponent } from './user-profile.component';
|
|
||||||
|
|
||||||
describe('UserProfileComponent',
|
|
||||||
() => {
|
|
||||||
let component: UserProfileComponent;
|
|
||||||
let fixture: ComponentFixture<UserProfileComponent>;
|
|
||||||
beforeEach(
|
|
||||||
waitForAsync(() => {
|
|
||||||
TestBed.configureTestingModule({
|
|
||||||
declarations: [UserProfileComponent],
|
|
||||||
}).compileComponents();
|
|
||||||
})
|
|
||||||
);
|
|
||||||
beforeEach(() => {
|
|
||||||
fixture = TestBed.createComponent(UserProfileComponent);
|
|
||||||
component = fixture.componentInstance;
|
|
||||||
fixture.detectChanges();
|
|
||||||
});
|
|
||||||
it('should create',
|
|
||||||
() => {
|
|
||||||
expect(component).toBeTruthy();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,27 +0,0 @@
|
|||||||
import { ComponentFixture, TestBed } from '@angular/core/testing';
|
|
||||||
|
|
||||||
import { BreadcrumbComponent } from './breadcrumb.component';
|
|
||||||
|
|
||||||
describe('BreadcrumbComponent',
|
|
||||||
() => {
|
|
||||||
let component: BreadcrumbComponent;
|
|
||||||
let fixture: ComponentFixture<BreadcrumbComponent>;
|
|
||||||
|
|
||||||
beforeEach(async () => {
|
|
||||||
await TestBed.configureTestingModule({
|
|
||||||
declarations: [BreadcrumbComponent]
|
|
||||||
})
|
|
||||||
.compileComponents();
|
|
||||||
});
|
|
||||||
|
|
||||||
beforeEach(() => {
|
|
||||||
fixture = TestBed.createComponent(BreadcrumbComponent);
|
|
||||||
component = fixture.componentInstance;
|
|
||||||
fixture.detectChanges();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should create',
|
|
||||||
() => {
|
|
||||||
expect(component).toBeTruthy();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,23 +0,0 @@
|
|||||||
import { ComponentFixture, TestBed } from '@angular/core/testing';
|
|
||||||
|
|
||||||
import { DatePeriodComponent } from './date-period.component';
|
|
||||||
|
|
||||||
describe('DatePeriodComponent', () => {
|
|
||||||
let component: DatePeriodComponent;
|
|
||||||
let fixture: ComponentFixture<DatePeriodComponent>;
|
|
||||||
|
|
||||||
beforeEach(async () => {
|
|
||||||
await TestBed.configureTestingModule({
|
|
||||||
declarations: [ DatePeriodComponent ]
|
|
||||||
})
|
|
||||||
.compileComponents();
|
|
||||||
|
|
||||||
fixture = TestBed.createComponent(DatePeriodComponent);
|
|
||||||
component = fixture.componentInstance;
|
|
||||||
fixture.detectChanges();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should create', () => {
|
|
||||||
expect(component).toBeTruthy();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,27 +0,0 @@
|
|||||||
import { ComponentFixture, TestBed } from '@angular/core/testing';
|
|
||||||
|
|
||||||
import { FeatherIconsComponent } from './feather-icons.component';
|
|
||||||
|
|
||||||
describe('FeatherIconsComponent',
|
|
||||||
() => {
|
|
||||||
let component: FeatherIconsComponent;
|
|
||||||
let fixture: ComponentFixture<FeatherIconsComponent>;
|
|
||||||
|
|
||||||
beforeEach(async () => {
|
|
||||||
await TestBed.configureTestingModule({
|
|
||||||
declarations: [FeatherIconsComponent]
|
|
||||||
})
|
|
||||||
.compileComponents();
|
|
||||||
});
|
|
||||||
|
|
||||||
beforeEach(() => {
|
|
||||||
fixture = TestBed.createComponent(FeatherIconsComponent);
|
|
||||||
component = fixture.componentInstance;
|
|
||||||
fixture.detectChanges();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should create',
|
|
||||||
() => {
|
|
||||||
expect(component).toBeTruthy();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,26 +0,0 @@
|
|||||||
import { async, ComponentFixture, TestBed } from '@angular/core/testing';
|
|
||||||
|
|
||||||
import { FileUploadComponent } from './file-upload.component';
|
|
||||||
|
|
||||||
describe('FileUploadComponent',
|
|
||||||
() => {
|
|
||||||
let component: FileUploadComponent;
|
|
||||||
let fixture: ComponentFixture<FileUploadComponent>;
|
|
||||||
|
|
||||||
beforeEach(async(() => {
|
|
||||||
TestBed.configureTestingModule({
|
|
||||||
declarations: [FileUploadComponent]
|
|
||||||
}).compileComponents();
|
|
||||||
}));
|
|
||||||
|
|
||||||
beforeEach(() => {
|
|
||||||
fixture = TestBed.createComponent(FileUploadComponent);
|
|
||||||
component = fixture.componentInstance;
|
|
||||||
fixture.detectChanges();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should create',
|
|
||||||
() => {
|
|
||||||
expect(component).toBeTruthy();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,18 +0,0 @@
|
|||||||
// Browser → host ports (defaults; Docker build may overwrite via API_PUBLIC_URL).
|
|
||||||
export const environment = {
|
|
||||||
production: true,
|
|
||||||
apiUrl: 'http://localhost:32081',
|
|
||||||
identityServer: 'http://localhost:32081/',
|
|
||||||
allowedUrls: ['http://localhost:32081'],
|
|
||||||
// Plain HTTP demo stack — do not require HTTPS for OIDC.
|
|
||||||
requireHttps: false,
|
|
||||||
externalLogins: {
|
|
||||||
google: {
|
|
||||||
clientId: ''
|
|
||||||
},
|
|
||||||
auth0: {
|
|
||||||
clientId: '',
|
|
||||||
domain: ''
|
|
||||||
}
|
|
||||||
}
|
|
||||||
};
|
|
||||||
@@ -1,11 +1,12 @@
|
|||||||
// Same-origin Proxmox deploy: API serves SPA from wwwroot (one public URL).
|
// Same-origin Proxmox deploy: API serves SPA from wwwroot (one public URL).
|
||||||
// Empty apiUrl / identityServer / allowedUrls → resolved at runtime from window.location.origin.
|
// Empty apiUrl / identityServer → resolved at runtime from window.location.origin.
|
||||||
|
// allowedUrls '/api' so angular-oauth2-oidc attaches Bearer to relative /api/... calls.
|
||||||
export const environment = {
|
export const environment = {
|
||||||
production: true,
|
production: true,
|
||||||
apiUrl: '',
|
apiUrl: '',
|
||||||
identityServer: '',
|
identityServer: '',
|
||||||
allowedUrls: [] as string[],
|
allowedUrls: ['/api'] as string[],
|
||||||
requireHttps: false,
|
requireHttps: true,
|
||||||
externalLogins: {
|
externalLogins: {
|
||||||
google: {
|
google: {
|
||||||
clientId: ''
|
clientId: ''
|
||||||
|
|||||||
@@ -1,5 +0,0 @@
|
|||||||
{
|
|
||||||
"ConnectionStrings": {
|
|
||||||
"npgsql": "Host=postgres;Port=5432;Database=myoffice;Username=myoffice;Password=myoffice"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,65 +0,0 @@
|
|||||||
using MyOffice.Data.Models.Accounts;
|
|
||||||
using MyOffice.Services.Account;
|
|
||||||
using AccountEntity = MyOffice.Data.Models.Accounts.Account;
|
|
||||||
|
|
||||||
namespace MyOffice.Tests.Accounts;
|
|
||||||
|
|
||||||
public class AccountAclTests
|
|
||||||
{
|
|
||||||
private static AccountEntity CreateAccount(Guid userId, bool write, bool manage)
|
|
||||||
{
|
|
||||||
return new AccountEntity
|
|
||||||
{
|
|
||||||
Id = Guid.NewGuid(),
|
|
||||||
Name = "Cash",
|
|
||||||
AccessRights =
|
|
||||||
[
|
|
||||||
new AccountAccess
|
|
||||||
{
|
|
||||||
UserId = userId,
|
|
||||||
IsAllowRead = true,
|
|
||||||
IsAllowWrite = write,
|
|
||||||
IsAllowManage = manage,
|
|
||||||
}
|
|
||||||
]
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
[Fact]
|
|
||||||
public void CanWrite_true_when_flag_set()
|
|
||||||
{
|
|
||||||
var userId = Guid.NewGuid();
|
|
||||||
var account = CreateAccount(userId, write: true, manage: false);
|
|
||||||
|
|
||||||
Assert.True(AccountAcl.CanWrite(account, userId));
|
|
||||||
Assert.False(AccountAcl.CanManage(account, userId));
|
|
||||||
}
|
|
||||||
|
|
||||||
[Fact]
|
|
||||||
public void CanManage_true_when_flag_set()
|
|
||||||
{
|
|
||||||
var userId = Guid.NewGuid();
|
|
||||||
var account = CreateAccount(userId, write: false, manage: true);
|
|
||||||
|
|
||||||
Assert.False(AccountAcl.CanWrite(account, userId));
|
|
||||||
Assert.True(AccountAcl.CanManage(account, userId));
|
|
||||||
}
|
|
||||||
|
|
||||||
[Fact]
|
|
||||||
public void CanWrite_false_for_other_user()
|
|
||||||
{
|
|
||||||
var account = CreateAccount(Guid.NewGuid(), write: true, manage: true);
|
|
||||||
|
|
||||||
Assert.False(AccountAcl.CanWrite(account, Guid.NewGuid()));
|
|
||||||
Assert.False(AccountAcl.CanManage(account, Guid.NewGuid()));
|
|
||||||
}
|
|
||||||
|
|
||||||
[Fact]
|
|
||||||
public void CanWrite_false_when_access_rights_null()
|
|
||||||
{
|
|
||||||
var account = new AccountEntity { Id = Guid.NewGuid(), Name = "X", AccessRights = null };
|
|
||||||
|
|
||||||
Assert.False(AccountAcl.CanWrite(account, Guid.NewGuid()));
|
|
||||||
Assert.False(AccountAcl.CanManage(account, Guid.NewGuid()));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,30 +0,0 @@
|
|||||||
using MyOffice.Services.Account;
|
|
||||||
|
|
||||||
namespace MyOffice.Tests.Accounts;
|
|
||||||
|
|
||||||
public class AccountMotionBalancingTests
|
|
||||||
{
|
|
||||||
[Fact]
|
|
||||||
public void ResolveAmounts_income_primary_creates_expense_on_balancing()
|
|
||||||
{
|
|
||||||
var (plus, minus) = AccountMotionBalancing.ResolveAmounts(primaryPlus: 100m, amountBalancing: 100m);
|
|
||||||
|
|
||||||
Assert.Equal(0m, plus);
|
|
||||||
Assert.Equal(100m, minus);
|
|
||||||
}
|
|
||||||
|
|
||||||
[Fact]
|
|
||||||
public void ResolveAmounts_expense_primary_creates_income_on_balancing()
|
|
||||||
{
|
|
||||||
var (plus, minus) = AccountMotionBalancing.ResolveAmounts(primaryPlus: 0m, amountBalancing: 50m);
|
|
||||||
|
|
||||||
Assert.Equal(50m, plus);
|
|
||||||
Assert.Equal(0m, minus);
|
|
||||||
}
|
|
||||||
|
|
||||||
[Fact]
|
|
||||||
public void BalancingItemName_prefixes_account_name()
|
|
||||||
{
|
|
||||||
Assert.Equal("+Wallet", AccountMotionBalancing.BalancingItemName("Wallet"));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,173 +0,0 @@
|
|||||||
using AutoMapper;
|
|
||||||
using Microsoft.Extensions.Logging;
|
|
||||||
using Microsoft.Extensions.Logging.Abstractions;
|
|
||||||
using Moq;
|
|
||||||
using MyOffice.Data.Models.Accounts;
|
|
||||||
using MyOffice.Data.Models.Items;
|
|
||||||
using MyOffice.Data.Repositories.Account;
|
|
||||||
using MyOffice.Data.Repositories.Currency;
|
|
||||||
using MyOffice.Data.Repositories.Item;
|
|
||||||
using MyOffice.Services.Account;
|
|
||||||
using MyOffice.Services.Account.Domain;
|
|
||||||
using MyOffice.Services.Identity;
|
|
||||||
using MyOffice.Services.Item;
|
|
||||||
|
|
||||||
using AccountEntity = MyOffice.Data.Models.Accounts.Account;
|
|
||||||
|
|
||||||
namespace MyOffice.Tests.Accounts;
|
|
||||||
|
|
||||||
public class MotionAddAclTests
|
|
||||||
{
|
|
||||||
[Fact]
|
|
||||||
public async Task MotionAddAsync_returns_forbidden_without_write_access()
|
|
||||||
{
|
|
||||||
var userId = Guid.NewGuid();
|
|
||||||
var accountId = Guid.NewGuid();
|
|
||||||
var account = new AccountEntity
|
|
||||||
{
|
|
||||||
Id = accountId,
|
|
||||||
Name = "Cash",
|
|
||||||
AccessRights =
|
|
||||||
[
|
|
||||||
new AccountAccess
|
|
||||||
{
|
|
||||||
UserId = userId,
|
|
||||||
IsAllowRead = true,
|
|
||||||
IsAllowWrite = false,
|
|
||||||
IsAllowManage = false,
|
|
||||||
}
|
|
||||||
]
|
|
||||||
};
|
|
||||||
|
|
||||||
var accountRepo = new Mock<IAccountRepository>();
|
|
||||||
accountRepo
|
|
||||||
.Setup(x => x.GetAsync(userId, accountId, It.IsAny<CancellationToken>()))
|
|
||||||
.ReturnsAsync(account);
|
|
||||||
|
|
||||||
var service = CreateService(accountRepo.Object);
|
|
||||||
|
|
||||||
var exec = await service.MotionAddAsync(
|
|
||||||
userId,
|
|
||||||
accountId,
|
|
||||||
new MotionAddUpdate
|
|
||||||
{
|
|
||||||
Date = DateTime.UtcNow,
|
|
||||||
Item = "Coffee",
|
|
||||||
Plus = 0,
|
|
||||||
Minus = 10,
|
|
||||||
});
|
|
||||||
|
|
||||||
Assert.Equal(MotionAddStatus.forbidden, exec.Status);
|
|
||||||
}
|
|
||||||
|
|
||||||
[Fact]
|
|
||||||
public async Task MotionAddAsync_skips_balancing_when_counterparty_not_writable()
|
|
||||||
{
|
|
||||||
var userId = Guid.NewGuid();
|
|
||||||
var primaryId = Guid.NewGuid();
|
|
||||||
var balancingId = Guid.NewGuid();
|
|
||||||
|
|
||||||
var primary = new AccountEntity
|
|
||||||
{
|
|
||||||
Id = primaryId,
|
|
||||||
Name = "Cash",
|
|
||||||
AccessRights =
|
|
||||||
[
|
|
||||||
new AccountAccess { UserId = userId, IsAllowRead = true, IsAllowWrite = true }
|
|
||||||
]
|
|
||||||
};
|
|
||||||
var balancing = new AccountEntity
|
|
||||||
{
|
|
||||||
Id = balancingId,
|
|
||||||
Name = "Bank",
|
|
||||||
AccessRights =
|
|
||||||
[
|
|
||||||
new AccountAccess { UserId = userId, IsAllowRead = true, IsAllowWrite = false }
|
|
||||||
]
|
|
||||||
};
|
|
||||||
|
|
||||||
var accountRepo = new Mock<IAccountRepository>();
|
|
||||||
accountRepo
|
|
||||||
.Setup(x => x.GetAsync(userId, primaryId, It.IsAny<CancellationToken>()))
|
|
||||||
.ReturnsAsync(primary);
|
|
||||||
accountRepo
|
|
||||||
.Setup(x => x.GetAsync(userId, balancingId, It.IsAny<CancellationToken>()))
|
|
||||||
.ReturnsAsync(balancing);
|
|
||||||
|
|
||||||
var motionRepo = new Mock<IMotionRepository>();
|
|
||||||
motionRepo
|
|
||||||
.Setup(x => x.AddAsync(It.IsAny<Motion>(), It.IsAny<CancellationToken>()))
|
|
||||||
.ReturnsAsync(true);
|
|
||||||
|
|
||||||
var service = CreateService(accountRepo.Object, motionRepo.Object);
|
|
||||||
|
|
||||||
var exec = await service.MotionAddAsync(
|
|
||||||
userId,
|
|
||||||
primaryId,
|
|
||||||
new MotionAddUpdate
|
|
||||||
{
|
|
||||||
Date = DateTime.UtcNow,
|
|
||||||
Item = "Transfer",
|
|
||||||
Plus = 0,
|
|
||||||
Minus = 25,
|
|
||||||
AccountId = balancingId.ToString("N"),
|
|
||||||
AmountBalancing = 25,
|
|
||||||
});
|
|
||||||
|
|
||||||
Assert.Equal(MotionAddStatus.success, exec.Status);
|
|
||||||
Assert.NotNull(exec.Result);
|
|
||||||
Assert.Single(exec.Result!);
|
|
||||||
motionRepo.Verify(x => x.AddAsync(It.IsAny<Motion>(), It.IsAny<CancellationToken>()), Times.Once);
|
|
||||||
}
|
|
||||||
|
|
||||||
private static ItemService CreateItemServiceStub()
|
|
||||||
{
|
|
||||||
var nextId = 1;
|
|
||||||
var itemRepo = new Mock<IItemRepository>();
|
|
||||||
var itemGlobalRepo = new Mock<IItemGlobalRepository>();
|
|
||||||
|
|
||||||
itemGlobalRepo
|
|
||||||
.Setup(x => x.GetByNameAsync(It.IsAny<string>(), It.IsAny<CancellationToken>()))
|
|
||||||
.ReturnsAsync((ItemGlobal?)null);
|
|
||||||
itemGlobalRepo
|
|
||||||
.Setup(x => x.AddAsync(It.IsAny<ItemGlobal>(), It.IsAny<CancellationToken>()))
|
|
||||||
.ReturnsAsync(true);
|
|
||||||
itemRepo
|
|
||||||
.Setup(x => x.GetByGlobalAsync(It.IsAny<Guid>(), It.IsAny<Guid>(), It.IsAny<CancellationToken>()))
|
|
||||||
.ReturnsAsync((Item?)null);
|
|
||||||
itemRepo
|
|
||||||
.Setup(x => x.AddAsync(It.IsAny<Item>(), It.IsAny<CancellationToken>()))
|
|
||||||
.ReturnsAsync(true)
|
|
||||||
.Callback<Item, CancellationToken>((i, _) => i.Id = nextId++);
|
|
||||||
|
|
||||||
return new ItemService(
|
|
||||||
Mock.Of<IItemCategoryRepository>(),
|
|
||||||
itemRepo.Object,
|
|
||||||
itemGlobalRepo.Object,
|
|
||||||
new MapperConfiguration(_ => { }, NullLoggerFactory.Instance).CreateMapper());
|
|
||||||
}
|
|
||||||
|
|
||||||
private static AccountService CreateService(
|
|
||||||
IAccountRepository accountRepository,
|
|
||||||
IMotionRepository? motionRepository = null)
|
|
||||||
{
|
|
||||||
var mapper = new MapperConfiguration(
|
|
||||||
cfg => cfg.AddProfile<MotionDtoProfile>(),
|
|
||||||
NullLoggerFactory.Instance).CreateMapper();
|
|
||||||
|
|
||||||
return new AccountService(
|
|
||||||
Mock.Of<ILogger<AccountService>>(),
|
|
||||||
mapper,
|
|
||||||
Mock.Of<IAccountCategoryRepository>(),
|
|
||||||
Mock.Of<IAccountAccessRepository>(),
|
|
||||||
Mock.Of<IAccountAccessInviteRepository>(),
|
|
||||||
accountRepository,
|
|
||||||
Mock.Of<ICurrencyRepository>(),
|
|
||||||
Mock.Of<IAccountAccountCategoryRepository>(),
|
|
||||||
motionRepository ?? Mock.Of<IMotionRepository>(),
|
|
||||||
Mock.Of<IItemRepository>(),
|
|
||||||
Mock.Of<IItemGlobalRepository>(),
|
|
||||||
CreateItemServiceStub(),
|
|
||||||
Mock.Of<IContextProvider>());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,22 +0,0 @@
|
|||||||
using MyOffice.Web.Auth;
|
|
||||||
|
|
||||||
namespace MyOffice.Tests.Auth;
|
|
||||||
|
|
||||||
public class OpenIddictRedirectUriTests
|
|
||||||
{
|
|
||||||
[Fact]
|
|
||||||
public void BuildRedirectUri_uses_localhost_when_host_empty()
|
|
||||||
{
|
|
||||||
var uri = OpenIddictSeeder.BuildRedirectUri(null);
|
|
||||||
|
|
||||||
Assert.Equal("http://localhost:4300/silent-refresh.html", uri.ToString());
|
|
||||||
}
|
|
||||||
|
|
||||||
[Fact]
|
|
||||||
public void BuildRedirectUri_appends_silent_refresh_and_trims_slash()
|
|
||||||
{
|
|
||||||
var uri = OpenIddictSeeder.BuildRedirectUri("https://app.example.com/");
|
|
||||||
|
|
||||||
Assert.Equal("https://app.example.com/silent-refresh.html", uri.ToString());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,56 +0,0 @@
|
|||||||
using Microsoft.AspNetCore.Identity;
|
|
||||||
using MyOffice.Web.Identity.Domain;
|
|
||||||
using MyOffice.Web.Identity.Repositories;
|
|
||||||
|
|
||||||
namespace MyOffice.Tests.Auth;
|
|
||||||
|
|
||||||
public class PasswordHasherTests
|
|
||||||
{
|
|
||||||
private readonly PasswordHasher _hasher = new();
|
|
||||||
private readonly ApplicationUser<Guid> _user = new() { Id = Guid.NewGuid(), UserName = "test@example.com" };
|
|
||||||
|
|
||||||
[Fact]
|
|
||||||
public void HashPassword_then_verify_succeeds_with_identity_format()
|
|
||||||
{
|
|
||||||
var hash = _hasher.HashPassword(_user, "P@ssw0rd!");
|
|
||||||
|
|
||||||
Assert.Equal(PasswordVerificationResult.Success, _hasher.VerifyHashedPassword(_user, hash, "P@ssw0rd!"));
|
|
||||||
}
|
|
||||||
|
|
||||||
[Fact]
|
|
||||||
public void VerifyHashedPassword_fails_for_wrong_password()
|
|
||||||
{
|
|
||||||
var hash = _hasher.HashPassword(_user, "P@ssw0rd!");
|
|
||||||
|
|
||||||
Assert.Equal(PasswordVerificationResult.Failed, _hasher.VerifyHashedPassword(_user, hash, "wrong"));
|
|
||||||
}
|
|
||||||
|
|
||||||
[Fact]
|
|
||||||
public void HashPassword_produces_distinct_hashes_due_to_salt()
|
|
||||||
{
|
|
||||||
var a = _hasher.HashPassword(_user, "same");
|
|
||||||
var b = _hasher.HashPassword(_user, "same");
|
|
||||||
|
|
||||||
Assert.NotEqual(a, b);
|
|
||||||
}
|
|
||||||
|
|
||||||
[Fact]
|
|
||||||
public void VerifyHashedPassword_legacy_hash_returns_rehash_needed()
|
|
||||||
{
|
|
||||||
var legacy = PasswordHasher.HashLegacyForTests("legacy-secret");
|
|
||||||
|
|
||||||
Assert.Equal(
|
|
||||||
PasswordVerificationResult.SuccessRehashNeeded,
|
|
||||||
_hasher.VerifyHashedPassword(_user, legacy, "legacy-secret"));
|
|
||||||
}
|
|
||||||
|
|
||||||
[Fact]
|
|
||||||
public void VerifyHashedPassword_legacy_wrong_password_fails()
|
|
||||||
{
|
|
||||||
var legacy = PasswordHasher.HashLegacyForTests("legacy-secret");
|
|
||||||
|
|
||||||
Assert.Equal(
|
|
||||||
PasswordVerificationResult.Failed,
|
|
||||||
_hasher.VerifyHashedPassword(_user, legacy, "nope"));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,54 +0,0 @@
|
|||||||
using Microsoft.Data.Sqlite;
|
|
||||||
using Microsoft.EntityFrameworkCore;
|
|
||||||
using MyOffice.DbContext;
|
|
||||||
|
|
||||||
namespace MyOffice.Tests.Data;
|
|
||||||
|
|
||||||
public class DemoDataSeederTests
|
|
||||||
{
|
|
||||||
[Fact]
|
|
||||||
public async Task SeedIfEmpty_creates_demo_users_catalog_and_motions()
|
|
||||||
{
|
|
||||||
var path = Path.Combine(Path.GetTempPath(), $"myoffice-seed-{Guid.NewGuid():N}.db");
|
|
||||||
var connection = new ConnectionConfiguration("sqlite", $"Data Source={path}");
|
|
||||||
var options = new DbContextOptionsBuilder<AppDbContext>();
|
|
||||||
DbContextServiceCollectionExtensions.ConfigureDbContextOptions(options, connection);
|
|
||||||
|
|
||||||
await using (var db = new AppDbContext(AppDbContextProvidersEnum.sqlite, options.Options))
|
|
||||||
{
|
|
||||||
await DatabaseBootstrapper.InitializeAsync(db);
|
|
||||||
|
|
||||||
Assert.Equal(3, await db.Users.CountAsync());
|
|
||||||
Assert.Equal(9, await db.Currencies.CountAsync());
|
|
||||||
Assert.Equal(9, await db.CurrencyRates.CountAsync());
|
|
||||||
Assert.Equal(9, await db.AccountCategories.CountAsync());
|
|
||||||
Assert.Equal(27, await db.Accounts.CountAsync());
|
|
||||||
Assert.Equal(300, await db.Motions.CountAsync());
|
|
||||||
|
|
||||||
var uahUser = await db.Users.SingleAsync(x => x.Email == "user_UAH@user_UAH.userUAH");
|
|
||||||
Assert.Equal("UAH", uahUser.CurrencyId);
|
|
||||||
|
|
||||||
var uahRates = await db.CurrencyRates
|
|
||||||
.Include(x => x.Currency)
|
|
||||||
.Where(x => x.Currency!.UserId == uahUser.Id)
|
|
||||||
.ToListAsync();
|
|
||||||
Assert.Equal(1m, uahRates.Single(x => x.Currency!.CurrencyGlobalId == "UAH").Rate);
|
|
||||||
Assert.Equal(42m, uahRates.Single(x => x.Currency!.CurrencyGlobalId == "USD").Rate);
|
|
||||||
Assert.Equal(50m, uahRates.Single(x => x.Currency!.CurrencyGlobalId == "EUR").Rate);
|
|
||||||
|
|
||||||
Assert.Contains(await db.AccountCategories.Where(x => x.UserId == uahUser.Id).Select(x => x.Name).ToListAsync(),
|
|
||||||
x => x == "Готівка");
|
|
||||||
Assert.Contains(await db.ItemCategories.Where(x => x.UserId == uahUser.Id).Select(x => x.Name).ToListAsync(),
|
|
||||||
x => x == "Доходи");
|
|
||||||
|
|
||||||
await DemoDataSeeder.SeedIfEmptyAsync(db);
|
|
||||||
Assert.Equal(3, await db.Users.CountAsync());
|
|
||||||
}
|
|
||||||
|
|
||||||
SqliteConnection.ClearAllPools();
|
|
||||||
if (File.Exists(path))
|
|
||||||
{
|
|
||||||
File.Delete(path);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,29 +0,0 @@
|
|||||||
<Project Sdk="Microsoft.NET.Sdk">
|
|
||||||
|
|
||||||
<PropertyGroup>
|
|
||||||
<TargetFramework>net10.0</TargetFramework>
|
|
||||||
<ImplicitUsings>enable</ImplicitUsings>
|
|
||||||
<Nullable>enable</Nullable>
|
|
||||||
<IsPackable>false</IsPackable>
|
|
||||||
</PropertyGroup>
|
|
||||||
|
|
||||||
<ItemGroup>
|
|
||||||
<PackageReference Include="AutoMapper" Version="16.2.0" />
|
|
||||||
<PackageReference Include="coverlet.collector" Version="6.0.4" />
|
|
||||||
<PackageReference Include="Microsoft.Extensions.Logging.Abstractions" Version="10.0.10" />
|
|
||||||
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.14.1" />
|
|
||||||
<PackageReference Include="Moq" Version="4.20.72" />
|
|
||||||
<PackageReference Include="xunit" Version="2.9.3" />
|
|
||||||
<PackageReference Include="xunit.runner.visualstudio" Version="3.1.4" />
|
|
||||||
</ItemGroup>
|
|
||||||
|
|
||||||
<ItemGroup>
|
|
||||||
<Using Include="Xunit" />
|
|
||||||
</ItemGroup>
|
|
||||||
|
|
||||||
<ItemGroup>
|
|
||||||
<ProjectReference Include="..\MyOffice.Services\MyOffice.Services.csproj" />
|
|
||||||
<ProjectReference Include="..\MyOffice.Web\MyOffice.Web.csproj" />
|
|
||||||
</ItemGroup>
|
|
||||||
|
|
||||||
</Project>
|
|
||||||
@@ -49,6 +49,15 @@ public static class OpenIddictServiceCollectionExtensions
|
|||||||
|
|
||||||
ConfigureCryptography(options, configuration, environment);
|
ConfigureCryptography(options, configuration, environment);
|
||||||
|
|
||||||
|
// Public URL behind nginx — discovery issuer must match SPA window.location.origin,
|
||||||
|
// not the CT LAN address Kestrel sees on the wire.
|
||||||
|
var publicHost = configuration.GetValue<string>("FrontEnd:Host")?.Trim().TrimEnd('/');
|
||||||
|
if (!string.IsNullOrWhiteSpace(publicHost)
|
||||||
|
&& Uri.TryCreate(publicHost + "/", UriKind.Absolute, out var issuerUri))
|
||||||
|
{
|
||||||
|
options.SetIssuer(issuerUri);
|
||||||
|
}
|
||||||
|
|
||||||
var aspNetCoreBuilder = options.UseAspNetCore()
|
var aspNetCoreBuilder = options.UseAspNetCore()
|
||||||
.EnableAuthorizationEndpointPassthrough()
|
.EnableAuthorizationEndpointPassthrough()
|
||||||
.EnableUserInfoEndpointPassthrough()
|
.EnableUserInfoEndpointPassthrough()
|
||||||
|
|||||||
@@ -1,31 +0,0 @@
|
|||||||
{
|
|
||||||
"DatabaseProvider": "npgsql",
|
|
||||||
"Logging": {
|
|
||||||
"LogLevel": {
|
|
||||||
"Default": "Information",
|
|
||||||
"Microsoft.AspNetCore": "Warning"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"Kestrel": {
|
|
||||||
"EndPoints": {
|
|
||||||
"Http": {
|
|
||||||
"Url": "http://*:8080"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"FrontEnd": {
|
|
||||||
"Host": "http://localhost:32080"
|
|
||||||
},
|
|
||||||
"Cors": {
|
|
||||||
"AllowedOrigins": [
|
|
||||||
"http://localhost:32080",
|
|
||||||
"http://127.0.0.1:32080"
|
|
||||||
]
|
|
||||||
},
|
|
||||||
"OpenIddict": {
|
|
||||||
"SigningKeyPath": "",
|
|
||||||
"EncryptionKeyPath": "",
|
|
||||||
"UseEphemeralKeys": true,
|
|
||||||
"AllowHttp": true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -27,6 +27,7 @@ Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "MyOffice.Shared", "MyOffice
|
|||||||
EndProject
|
EndProject
|
||||||
Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Tools", "Tools", "{06517FE0-8E41-4124-A482-F21382A62687}"
|
Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Tools", "Tools", "{06517FE0-8E41-4124-A482-F21382A62687}"
|
||||||
ProjectSection(SolutionItems) = preProject
|
ProjectSection(SolutionItems) = preProject
|
||||||
|
.gitignore = .gitignore
|
||||||
gulpfile.js = gulpfile.js
|
gulpfile.js = gulpfile.js
|
||||||
EndProjectSection
|
EndProjectSection
|
||||||
EndProject
|
EndProject
|
||||||
|
|||||||
@@ -0,0 +1,53 @@
|
|||||||
|
# MyOffice
|
||||||
|
|
||||||
|
ASP.NET Core API + Angular SPA. This repository has the **source needed to build**, plus Proxmox install scripts. Runnable binaries are **Gitea Releases** (`myoffice-publish.tar.gz`).
|
||||||
|
|
||||||
|
## Install on Proxmox (one line)
|
||||||
|
|
||||||
|
On the Proxmox host:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
bash -c "$(curl -fsSL https://mygit.ase.com.ua/alexandr/myoffice_public/raw/branch/master/proxmox/myoffice.sh)"
|
||||||
|
```
|
||||||
|
|
||||||
|
If the CT cannot reach Gitea by DNS (hairpin), pass the nginx/proxy LAN IP as an argument — it is written into the CT `/etc/hosts` for `mygit.ase.com.ua`:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
bash -c "$(curl -fsSL https://mygit.ase.com.ua/alexandr/myoffice_public/raw/branch/master/proxmox/myoffice.sh)" 192.168.100.10
|
||||||
|
```
|
||||||
|
|
||||||
|
Creates an LXC with PostgreSQL + ASP.NET runtime, downloads the latest release, starts the app on port **9100**. Point your nginx LXC at `http://<ct-ip>:9100`.
|
||||||
|
|
||||||
|
Optional overrides:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
export RELEASE_TAG="latest" # or v1.2.3
|
||||||
|
export GITEA_HOSTS_IP="192.168.100.10" # same as the trailing arg
|
||||||
|
```
|
||||||
|
|
||||||
|
Details: [proxmox/README.md](proxmox/README.md).
|
||||||
|
|
||||||
|
## Build from source
|
||||||
|
|
||||||
|
Requirements: .NET 10 SDK, Node.js 20+.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
./proxmox/publish.sh --tar
|
||||||
|
# Windows: .\proxmox\publish.ps1 -Tar
|
||||||
|
```
|
||||||
|
|
||||||
|
Output: `_Published/` (API + SPA in `wwwroot`) and optional `myoffice-publish.tar.gz`.
|
||||||
|
|
||||||
|
Local development:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
dotnet build MyOffice.Web/MyOffice.Web.csproj
|
||||||
|
cd MyOffice.SPA && npm start # after npm install; http://localhost:4300
|
||||||
|
# Run MyOffice.Web (API http://localhost:9100); set FrontEnd:Host to the SPA origin
|
||||||
|
```
|
||||||
|
|
||||||
|
## Demo logins
|
||||||
|
|
||||||
|
Seeded on empty DB:
|
||||||
|
|
||||||
|
- `user_UAH@user_UAH.userUAH` / `user_UAH` (also USD, EUR variants)
|
||||||
@@ -0,0 +1,149 @@
|
|||||||
|
# MyOffice on Proxmox (Gitea releases)
|
||||||
|
|
||||||
|
Three-tier setup on **your Gitea**:
|
||||||
|
|
||||||
|
| Tier | Repo | Visibility | Contents |
|
||||||
|
|------|------|------------|----------|
|
||||||
|
| 1. Dev | `myoffice` | Private | Full source + Actions |
|
||||||
|
| 2. Public code | `myoffice-public` | Public | Build tree (`.publishinclude`) + Proxmox scripts + public README |
|
||||||
|
| 3. Published app | Releases on `myoffice-public` | Public | `myoffice-publish.tar.gz` |
|
||||||
|
|
||||||
|
Public sync **does not** include Docker, private `.gitignore`, `*.bat`, tests, docs, or private Actions (`.gitea/`). See `.publishinclude`.
|
||||||
|
|
||||||
|
|
||||||
|
CT install downloads the **release** tarball (not source). No Docker/nginx/SDK/Node inside the CT — proxy with your existing nginx LXC to Kestrel `:9100`.
|
||||||
|
|
||||||
|
```text
|
||||||
|
[private myoffice] --sync-public--> [myoffice-public full source + README]
|
||||||
|
[private myoffice] --publish+release--> [Gitea Release .tar.gz]
|
||||||
|
|
|
||||||
|
[User Proxmox] one-line curl myoffice.sh --> [LXC: Postgres + ASP.NET + app]
|
||||||
|
^
|
||||||
|
[User nginx] proxy_pass http://ct-ip:9100 ----+
|
||||||
|
```
|
||||||
|
|
||||||
|
## End user: create CT (one line)
|
||||||
|
|
||||||
|
Shown on the **public** repo `README.md` after sync. Example for this instance:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
bash -c "$(curl -fsSL https://mygit.ase.com.ua/alexandr/myoffice_public/raw/branch/master/proxmox/myoffice.sh)"
|
||||||
|
# Optional trailing IP → CT /etc/hosts for mygit (when hairpin DNS fails):
|
||||||
|
# bash -c "$(curl -fsSL …/proxmox/myoffice.sh)" 192.168.100.10
|
||||||
|
```
|
||||||
|
|
||||||
|
Optional env before the command: `GITEA_URL`, `GITEA_OWNER`, `GITEA_REPO`, `GITEA_BRANCH`, `RELEASE_TAG` (`latest` or `v1.2.3`).
|
||||||
|
|
||||||
|
Whiptail: Default / Advanced (Public URL, release tag) → creates CT → installs Postgres + ASP.NET runtime → downloads release → configures FrontEnd/CORS + local Postgres → systemd.
|
||||||
|
|
||||||
|
## End user: build from public source
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git clone https://mygit.ase.com.ua/alexandr/myoffice_public.git
|
||||||
|
cd myoffice_public
|
||||||
|
./proxmox/publish.sh --tar
|
||||||
|
```
|
||||||
|
|
||||||
|
## Maintainer: publish a release
|
||||||
|
|
||||||
|
On the private repo (build PC or Gitea Actions):
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
$env:GITEA_TOKEN = '<token with write:repository on myoffice-public>'
|
||||||
|
|
||||||
|
.\proxmox\publish.ps1 -Tar
|
||||||
|
.\proxmox\release.ps1 -GiteaUrl https://mygit.ase.com.ua -Owner alexandr -Repo myoffice_public -Tag v1.2.3
|
||||||
|
.\proxmox\sync-public.ps1 -GiteaUrl https://mygit.ase.com.ua -Owner alexandr -Repo myoffice_public -Branch master -Push
|
||||||
|
```
|
||||||
|
|
||||||
|
- `proxmox/publish.ps1` → `_Published/` + `myoffice-publish.tar.gz`
|
||||||
|
- `proxmox/release.ps1` → Gitea Release asset
|
||||||
|
- `proxmox/sync-public.ps1` → allowlisted build tree + public `README.md` (from `README.public.md`)
|
||||||
|
|
||||||
|
|
||||||
|
Or via Gitea Actions on the private repo (`.gitea/workflows/publish.yml`): push `master` → tag `latest`, or push tag `v*` / manual dispatch.
|
||||||
|
|
||||||
|
Lab-only SSH deploy (no Gitea): `.\proxmox\deploy.ps1 -TargetHost <ct-ip>`
|
||||||
|
|
||||||
|
## What runs inside the CT
|
||||||
|
|
||||||
|
| Service | How | Port |
|
||||||
|
|---------|-----|------|
|
||||||
|
| PostgreSQL | apt + systemd | `127.0.0.1:5432` |
|
||||||
|
| MyOffice API + SPA | `dotnet` via `myoffice-api.service` | **9100** |
|
||||||
|
|
||||||
|
```text
|
||||||
|
/opt/myoffice/api # extracted release (API + wwwroot)
|
||||||
|
/opt/myoffice/proxmox # install/update helpers + env
|
||||||
|
```
|
||||||
|
|
||||||
|
Config chain:
|
||||||
|
|
||||||
|
- **Front → API:** same-origin SPA (`environment.proxmox.ts`)
|
||||||
|
- **API → Postgres:** CT `appsettings.shared.Production.json`
|
||||||
|
- **Browser → app:** nginx → `http://<ct-ip>:9100`
|
||||||
|
|
||||||
|
## Proxy (your nginx LXC)
|
||||||
|
|
||||||
|
```nginx
|
||||||
|
location / {
|
||||||
|
proxy_pass http://<myoffice-ct-ip>:9100;
|
||||||
|
proxy_http_version 1.1;
|
||||||
|
proxy_set_header Host $host;
|
||||||
|
proxy_set_header X-Forwarded-Host $host;
|
||||||
|
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||||
|
proxy_set_header X-Forwarded-Proto $scheme;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
In Advanced install, set **Public URL** to the exact browser URL (e.g. `https://office.ase.com.ua`) so CORS / OpenIddict issuer / `FrontEnd:Host` match.
|
||||||
|
|
||||||
|
## Update later
|
||||||
|
|
||||||
|
```bash
|
||||||
|
pct enter <CTID>
|
||||||
|
/opt/myoffice/proxmox/update.sh # re-download RELEASE_TAG + restart
|
||||||
|
/opt/myoffice/proxmox/update.sh logs
|
||||||
|
/opt/myoffice/proxmox/update.sh status
|
||||||
|
```
|
||||||
|
|
||||||
|
Release settings are stored in `/opt/myoffice/proxmox/env`.
|
||||||
|
|
||||||
|
## Gitea ops checklist
|
||||||
|
|
||||||
|
- Create private `myoffice` and public `myoffice-public`
|
||||||
|
- Raise release attachment size limit if needed
|
||||||
|
- Token for maintainers: `write:repository` on public repo
|
||||||
|
- TLS (or trust custom CA) so Proxmox/`curl` can download
|
||||||
|
- Private repo secret `PUBLIC_TOKEN` for Actions (`.gitea/workflows/publish.yml`)
|
||||||
|
|
||||||
|
## Gitea runner caching
|
||||||
|
|
||||||
|
Three layers (independent):
|
||||||
|
|
||||||
|
| Layer | What | How |
|
||||||
|
|-------|------|-----|
|
||||||
|
| Toolcache | .NET SDK / Node binaries | `setup-dotnet` / `setup-node` write to `/opt/hostedtoolcache`. Persist it on the **act_runner** host (Docker volume) so the next job reuses downloads (steps still run, but skip re-download — you already saw `Found in cache @ /opt/hostedtoolcache/...`). |
|
||||||
|
| Actions cache | NuGet + npm packages | Workflow uses `actions/cache@v4` for `~/.nuget/packages` and `~/.npm`. Needs the runner cache server (default on act_runner). |
|
||||||
|
| Skip setup | No setup steps at all | Bake SDK + Node into the job Docker image (or host runner), then set job env `SKIP_SETUP_DOTNET=true` and `SKIP_SETUP_NODE=true`. |
|
||||||
|
|
||||||
|
Example act_runner volume (compose):
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
volumes:
|
||||||
|
- /var/run/docker.sock:/var/run/docker.sock
|
||||||
|
- act-toolcache:/opt/hostedtoolcache # persist SDK/Node between jobs
|
||||||
|
```
|
||||||
|
|
||||||
|
If jobs use ephemeral containers without that volume, SDKs download every run.
|
||||||
|
|
||||||
|
## Defaults
|
||||||
|
|
||||||
|
- Debian 12 LXC, unprivileged, `--onboot 1`
|
||||||
|
- Prompts: **IP** (DHCP or static), **CPU** (default 2), **RAM** (default 2048 MiB), **Disk** (default 10 GiB)
|
||||||
|
- Bridge `vmbr0` / listen `:9100`
|
||||||
|
|
||||||
|
## Demo logins
|
||||||
|
|
||||||
|
- App: `user_UAH@user_UAH.userUAH` / `user_UAH` (also USD, EUR)
|
||||||
|
- Postgres: user/db/password `myoffice`
|
||||||
@@ -0,0 +1,138 @@
|
|||||||
|
# Deploy prebuilt MyOffice artifacts to a Proxmox CT (or any Linux host).
|
||||||
|
# Usage:
|
||||||
|
# .\proxmox\publish.ps1
|
||||||
|
# .\proxmox\deploy.ps1 -TargetHost 192.168.1.120
|
||||||
|
# .\proxmox\deploy.ps1 -TargetHost 192.168.1.120 -User root -RemotePath /opt/myoffice/api
|
||||||
|
#
|
||||||
|
# Requires OpenSSH client (ssh/scp). Prefers rsync if available (Git Bash / WSL / cwRsync).
|
||||||
|
# Preserves CT-owned appsettings.Production.json / appsettings.shared.Production.json.
|
||||||
|
|
||||||
|
[CmdletBinding()]
|
||||||
|
param(
|
||||||
|
[Parameter(Mandatory = $true)]
|
||||||
|
[Alias('ComputerName', 'Host')]
|
||||||
|
[string]$TargetHost,
|
||||||
|
|
||||||
|
[string]$User = 'root',
|
||||||
|
|
||||||
|
[Alias('Path')]
|
||||||
|
[string]$RemotePath = '/opt/myoffice/api',
|
||||||
|
|
||||||
|
[string]$Service = 'myoffice-api',
|
||||||
|
|
||||||
|
[string]$PublishDir = '',
|
||||||
|
|
||||||
|
[int]$Port = 22
|
||||||
|
)
|
||||||
|
|
||||||
|
$ErrorActionPreference = 'Stop'
|
||||||
|
|
||||||
|
$scriptDir = Split-Path -Parent $MyInvocation.MyCommand.Path
|
||||||
|
$repoRoot = Resolve-Path (Join-Path $scriptDir '..')
|
||||||
|
|
||||||
|
if (-not $PublishDir) {
|
||||||
|
$PublishDir = Join-Path $repoRoot '_Published'
|
||||||
|
}
|
||||||
|
if (-not (Test-Path $PublishDir)) {
|
||||||
|
throw "Publish folder not found: $PublishDir — run .\proxmox\publish.ps1 first."
|
||||||
|
}
|
||||||
|
if (-not (Test-Path (Join-Path $PublishDir 'MyOffice.Web.dll'))) {
|
||||||
|
throw "MyOffice.Web.dll missing in $PublishDir — run .\proxmox\publish.ps1 first."
|
||||||
|
}
|
||||||
|
|
||||||
|
$ctOwnedSettings = @(
|
||||||
|
'appsettings.Production.json',
|
||||||
|
'appsettings.shared.Production.json'
|
||||||
|
)
|
||||||
|
|
||||||
|
$remote = "${User}@${TargetHost}"
|
||||||
|
$sshTarget = @('-p', "$Port", $remote)
|
||||||
|
$scpTarget = @('-P', "$Port")
|
||||||
|
|
||||||
|
function Invoke-Remote {
|
||||||
|
param([Parameter(Mandatory = $true)][string]$Command)
|
||||||
|
& ssh @sshTarget $Command
|
||||||
|
if ($LASTEXITCODE -ne 0) {
|
||||||
|
throw "ssh failed (exit $LASTEXITCODE): $Command"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function Test-Rsync {
|
||||||
|
return [bool](Get-Command rsync -ErrorAction SilentlyContinue)
|
||||||
|
}
|
||||||
|
|
||||||
|
function ConvertTo-RsyncPath([string]$WindowsPath) {
|
||||||
|
$p = ($WindowsPath.TrimEnd('\', '/') + '/') -replace '\\', '/'
|
||||||
|
if ($p -match '^[A-Za-z]:') {
|
||||||
|
$drive = $p.Substring(0, 1).ToLower()
|
||||||
|
$p = "/$drive" + $p.Substring(2)
|
||||||
|
}
|
||||||
|
return $p
|
||||||
|
}
|
||||||
|
|
||||||
|
Write-Host "-----------------------------------------------"
|
||||||
|
Write-Host "Deploy $PublishDir -> ${remote}:${RemotePath}"
|
||||||
|
Write-Host "-----------------------------------------------"
|
||||||
|
|
||||||
|
Write-Host "Stopping $Service..."
|
||||||
|
Invoke-Remote "systemctl stop $Service || true"
|
||||||
|
|
||||||
|
Invoke-Remote "mkdir -p '$RemotePath'"
|
||||||
|
|
||||||
|
$rsync = Test-Rsync
|
||||||
|
if ($rsync) {
|
||||||
|
Write-Host "Copying with rsync (preserve CT Production appsettings)..."
|
||||||
|
$rsyncSrc = ConvertTo-RsyncPath $PublishDir
|
||||||
|
$rsyncArgs = @(
|
||||||
|
'-r', '--info=progress2',
|
||||||
|
'--exclude', 'appsettings.Production.json',
|
||||||
|
'--exclude', 'appsettings.shared.Production.json',
|
||||||
|
'-e', "ssh -p $Port",
|
||||||
|
$rsyncSrc,
|
||||||
|
"${remote}:${RemotePath}/"
|
||||||
|
)
|
||||||
|
& rsync @rsyncArgs
|
||||||
|
if ($LASTEXITCODE -ne 0) {
|
||||||
|
throw "rsync failed with exit code $LASTEXITCODE"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
Write-Host "rsync not found — using tar+scp fallback..."
|
||||||
|
$staging = Join-Path $env:TEMP ("myoffice-deploy-" + [guid]::NewGuid().ToString('N'))
|
||||||
|
New-Item -ItemType Directory -Path $staging | Out-Null
|
||||||
|
try {
|
||||||
|
Get-ChildItem -Path $PublishDir -Force | Where-Object {
|
||||||
|
$_.Name -notin $ctOwnedSettings
|
||||||
|
} | ForEach-Object {
|
||||||
|
Copy-Item $_.FullName -Destination $staging -Recurse -Force
|
||||||
|
}
|
||||||
|
|
||||||
|
$tarLocal = Join-Path $env:TEMP 'myoffice-deploy.tar.gz'
|
||||||
|
if (Test-Path $tarLocal) { Remove-Item -Force $tarLocal }
|
||||||
|
Push-Location $staging
|
||||||
|
try {
|
||||||
|
tar -czf $tarLocal *
|
||||||
|
if ($LASTEXITCODE -ne 0) { throw "tar create failed" }
|
||||||
|
}
|
||||||
|
finally {
|
||||||
|
Pop-Location
|
||||||
|
}
|
||||||
|
|
||||||
|
& scp @scpTarget $tarLocal "${remote}:/tmp/myoffice-deploy.tar.gz"
|
||||||
|
if ($LASTEXITCODE -ne 0) { throw "scp failed" }
|
||||||
|
|
||||||
|
Invoke-Remote "tar -xzf /tmp/myoffice-deploy.tar.gz -C '$RemotePath' && rm -f /tmp/myoffice-deploy.tar.gz"
|
||||||
|
}
|
||||||
|
finally {
|
||||||
|
Remove-Item -Recurse -Force $staging -ErrorAction SilentlyContinue
|
||||||
|
Remove-Item -Force (Join-Path $env:TEMP 'myoffice-deploy.tar.gz') -ErrorAction SilentlyContinue
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Write-Host "Starting $Service..."
|
||||||
|
Invoke-Remote "systemctl start $Service"
|
||||||
|
Invoke-Remote "systemctl --no-pager --full status $Service || true"
|
||||||
|
|
||||||
|
Write-Host ""
|
||||||
|
Write-Host "Deployed to http://${TargetHost}:9100 (or your nginx public URL)"
|
||||||
|
Write-Host ""
|
||||||
@@ -0,0 +1,157 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# Download myoffice-publish.tar.gz from a Gitea release into API_DIR.
|
||||||
|
# Env:
|
||||||
|
# GITEA_URL, GITEA_OWNER, GITEA_REPO, RELEASE_TAG (latest|v1.2.3)
|
||||||
|
# API_DIR (default /opt/myoffice/api)
|
||||||
|
# RELEASE_ASSET_URL — optional direct override (skips API resolve)
|
||||||
|
# LOCAL_RELEASE_TAR — optional local .tar.gz (skips download; used when host pushes asset into CT)
|
||||||
|
# GITEA_TOKEN — optional for private release downloads
|
||||||
|
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
API_DIR="${API_DIR:-/opt/myoffice/api}"
|
||||||
|
GITEA_URL="${GITEA_URL:-}"
|
||||||
|
GITEA_OWNER="${GITEA_OWNER:-}"
|
||||||
|
GITEA_REPO="${GITEA_REPO:-myoffice_public}"
|
||||||
|
RELEASE_TAG="${RELEASE_TAG:-latest}"
|
||||||
|
RELEASE_ASSET_URL="${RELEASE_ASSET_URL:-}"
|
||||||
|
LOCAL_RELEASE_TAR="${LOCAL_RELEASE_TAR:-}"
|
||||||
|
ASSET_NAME="${ASSET_NAME:-myoffice-publish.tar.gz}"
|
||||||
|
GITEA_TOKEN="${GITEA_TOKEN:-}"
|
||||||
|
|
||||||
|
msg() { echo -e " ==> $*"; }
|
||||||
|
|
||||||
|
curl_auth() {
|
||||||
|
if [[ -n "$GITEA_TOKEN" ]]; then
|
||||||
|
curl -fsSL -H "Authorization: token ${GITEA_TOKEN}" "$@"
|
||||||
|
else
|
||||||
|
curl -fsSL "$@"
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
json_first_string() {
|
||||||
|
# Extract first "key":"value" for key=$1 from JSON on stdin (no jq/python required).
|
||||||
|
local key="$1"
|
||||||
|
tr -d '\n' | sed -n "s/.*\"${key}\"[[:space:]]*:[[:space:]]*\"\([^\"]*\)\".*/\1/p" | head -1
|
||||||
|
}
|
||||||
|
|
||||||
|
resolve_asset_url() {
|
||||||
|
if [[ -n "$RELEASE_ASSET_URL" ]]; then
|
||||||
|
echo "$RELEASE_ASSET_URL"
|
||||||
|
return 0
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [[ -z "$GITEA_URL" || -z "$GITEA_OWNER" ]]; then
|
||||||
|
echo "Set GITEA_URL + GITEA_OWNER (and GITEA_REPO), or RELEASE_ASSET_URL." >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
local base api_json tag browser_url
|
||||||
|
base="${GITEA_URL%/}"
|
||||||
|
|
||||||
|
if [[ "$RELEASE_TAG" == "latest" ]]; then
|
||||||
|
api_json=$(curl_auth "${base}/api/v1/repos/${GITEA_OWNER}/${GITEA_REPO}/releases/latest")
|
||||||
|
tag=$(printf '%s' "$api_json" | json_first_string tag_name)
|
||||||
|
if [[ -z "$tag" ]]; then
|
||||||
|
echo "Could not resolve latest release tag from Gitea API." >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
else
|
||||||
|
tag="$RELEASE_TAG"
|
||||||
|
api_json=$(curl_auth "${base}/api/v1/repos/${GITEA_OWNER}/${GITEA_REPO}/releases/tags/${tag}" || true)
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Prefer browser_download_url that ends with the asset name
|
||||||
|
browser_url=$(printf '%s' "${api_json:-}" | tr '"' '\n' | grep -F "/${ASSET_NAME}" | grep -E '^https?://' | head -1 || true)
|
||||||
|
|
||||||
|
if [[ -z "$browser_url" ]]; then
|
||||||
|
browser_url="${base}/${GITEA_OWNER}/${GITEA_REPO}/releases/download/${tag}/${ASSET_NAME}"
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "$browser_url"
|
||||||
|
}
|
||||||
|
|
||||||
|
# Download release asset to a file path (usable on Proxmox host).
|
||||||
|
download_asset_to() {
|
||||||
|
local dest="$1"
|
||||||
|
local url
|
||||||
|
url=$(resolve_asset_url)
|
||||||
|
msg "Downloading release asset: $url"
|
||||||
|
curl_auth -o "$dest" "$url"
|
||||||
|
msg "Saved $dest"
|
||||||
|
}
|
||||||
|
|
||||||
|
extract_tar_to_api() {
|
||||||
|
local tar_path="$1"
|
||||||
|
local staging prod shared_prod prod_bak="" shared_bak="" top_count top_dir only
|
||||||
|
|
||||||
|
mkdir -p "$API_DIR"
|
||||||
|
staging=$(mktemp -d /tmp/myoffice-extract.XXXXXX)
|
||||||
|
tar -xzf "$tar_path" -C "$staging"
|
||||||
|
|
||||||
|
prod="${API_DIR}/appsettings.Production.json"
|
||||||
|
shared_prod="${API_DIR}/appsettings.shared.Production.json"
|
||||||
|
if [[ -f "$prod" ]]; then
|
||||||
|
prod_bak=$(mktemp)
|
||||||
|
cp -a "$prod" "$prod_bak"
|
||||||
|
fi
|
||||||
|
if [[ -f "$shared_prod" ]]; then
|
||||||
|
shared_bak=$(mktemp)
|
||||||
|
cp -a "$shared_prod" "$shared_bak"
|
||||||
|
fi
|
||||||
|
|
||||||
|
top_count=$(find "$staging" -mindepth 1 -maxdepth 1 | wc -l)
|
||||||
|
top_dir=""
|
||||||
|
if [[ "$top_count" -eq 1 ]]; then
|
||||||
|
only=$(find "$staging" -mindepth 1 -maxdepth 1 | head -1)
|
||||||
|
if [[ -d "$only" ]]; then
|
||||||
|
top_dir="$only"
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
|
||||||
|
find "$API_DIR" -mindepth 1 -maxdepth 1 \
|
||||||
|
! -name 'appsettings.Production.json' \
|
||||||
|
! -name 'appsettings.shared.Production.json' \
|
||||||
|
-exec rm -rf {} +
|
||||||
|
|
||||||
|
if [[ -n "$top_dir" ]]; then
|
||||||
|
cp -a "$top_dir"/. "$API_DIR"/
|
||||||
|
else
|
||||||
|
cp -a "$staging"/. "$API_DIR"/
|
||||||
|
fi
|
||||||
|
rm -rf "$staging"
|
||||||
|
|
||||||
|
rm -f "$API_DIR/appsettings.Production.json" "$API_DIR/appsettings.shared.Production.json" 2>/dev/null || true
|
||||||
|
if [[ -n "$prod_bak" ]]; then
|
||||||
|
mv "$prod_bak" "$prod"
|
||||||
|
fi
|
||||||
|
if [[ -n "$shared_bak" ]]; then
|
||||||
|
mv "$shared_bak" "$shared_prod"
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [[ ! -f "${API_DIR}/MyOffice.Web.dll" ]]; then
|
||||||
|
echo "Extracted archive but MyOffice.Web.dll is missing in ${API_DIR}" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
msg "App extracted to ${API_DIR}"
|
||||||
|
}
|
||||||
|
|
||||||
|
download_and_extract() {
|
||||||
|
local tar_path
|
||||||
|
|
||||||
|
if [[ -n "$LOCAL_RELEASE_TAR" && -f "$LOCAL_RELEASE_TAR" ]]; then
|
||||||
|
msg "Using local release tar: $LOCAL_RELEASE_TAR"
|
||||||
|
tar_path="$LOCAL_RELEASE_TAR"
|
||||||
|
extract_tar_to_api "$tar_path"
|
||||||
|
return 0
|
||||||
|
fi
|
||||||
|
|
||||||
|
tar_path="/tmp/${ASSET_NAME}"
|
||||||
|
download_asset_to "$tar_path"
|
||||||
|
extract_tar_to_api "$tar_path"
|
||||||
|
rm -f "$tar_path"
|
||||||
|
}
|
||||||
|
|
||||||
|
if [[ "${BASH_SOURCE[0]}" == "${0}" ]]; then
|
||||||
|
download_and_extract
|
||||||
|
fi
|
||||||
@@ -0,0 +1,245 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# Runs inside the MyOffice LXC: PostgreSQL + ASP.NET Core runtime + app from Gitea Release.
|
||||||
|
# No Docker, no nginx, no git, no SDK, no Node.
|
||||||
|
#
|
||||||
|
# Env (from proxmox/myoffice.sh):
|
||||||
|
# APP_DIR, PUBLIC_URL, API_PORT, ASSETS_DIR
|
||||||
|
# GITEA_URL, GITEA_OWNER, GITEA_REPO, RELEASE_TAG, RELEASE_ASSET_URL, GITEA_TOKEN
|
||||||
|
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
APP_DIR="${APP_DIR:-/opt/myoffice}"
|
||||||
|
API_DIR="${APP_DIR}/api"
|
||||||
|
ASSETS_DIR="${ASSETS_DIR:-/tmp/myoffice-proxmox}"
|
||||||
|
PUBLIC_URL="${PUBLIC_URL:-}"
|
||||||
|
API_PORT="${API_PORT:-9100}"
|
||||||
|
PG_DB="myoffice"
|
||||||
|
PG_USER="myoffice"
|
||||||
|
PG_PASS="myoffice"
|
||||||
|
SKIP_FETCH="${SKIP_FETCH:-0}"
|
||||||
|
|
||||||
|
export DEBIAN_FRONTEND=noninteractive
|
||||||
|
export API_DIR
|
||||||
|
# Avoid perl locale warnings in minimal LXC (host may export unsupported LC_*).
|
||||||
|
export LANG=C.UTF-8
|
||||||
|
export LC_ALL=C.UTF-8
|
||||||
|
unset LANGUAGE LC_TIME LC_MESSAGES 2>/dev/null || true
|
||||||
|
|
||||||
|
msg() { echo -e " ==> $*"; }
|
||||||
|
|
||||||
|
# Optional: map Gitea hostname → LAN IP (GITEA_HOSTS_IP); skip if unset.
|
||||||
|
ensure_gitea_hosts() {
|
||||||
|
if [[ -z "${GITEA_HOSTS_IP:-}" ]]; then
|
||||||
|
return 0
|
||||||
|
fi
|
||||||
|
local hn="${GITEA_HOSTS_NAME:-mygit.ase.com.ua}"
|
||||||
|
sed -i "/[[:space:]]${hn}\$/d" /etc/hosts
|
||||||
|
echo "${GITEA_HOSTS_IP} ${hn}" >> /etc/hosts
|
||||||
|
msg "hosts: ${GITEA_HOSTS_IP} ${hn}"
|
||||||
|
}
|
||||||
|
|
||||||
|
as_postgres() {
|
||||||
|
# Script runs as root in CT; sudo is not installed.
|
||||||
|
# cd away from /root — postgres cannot access it (harmless but noisy otherwise).
|
||||||
|
if command -v runuser >/dev/null 2>&1; then
|
||||||
|
(cd /tmp && runuser -u postgres -- "$@")
|
||||||
|
else
|
||||||
|
(cd /tmp && su -s /bin/bash postgres -c "$(printf '%q ' "$@")")
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
detect_public_url() {
|
||||||
|
if [[ -n "$PUBLIC_URL" ]]; then
|
||||||
|
PUBLIC_URL=$(echo "$PUBLIC_URL" | sed 's:/*$::')
|
||||||
|
return 0
|
||||||
|
fi
|
||||||
|
local ip
|
||||||
|
ip=$(ip -4 -o addr show eth0 2>/dev/null | awk '{print $4}' | cut -d/ -f1 | head -1 || true)
|
||||||
|
if [[ -z "$ip" ]]; then
|
||||||
|
ip=$(hostname -I 2>/dev/null | awk '{print $1}')
|
||||||
|
fi
|
||||||
|
if [[ -z "$ip" ]]; then
|
||||||
|
echo "Could not detect CT IP. Set PUBLIC_URL." >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
PUBLIC_URL="http://${ip}:${API_PORT}"
|
||||||
|
}
|
||||||
|
|
||||||
|
install_base() {
|
||||||
|
msg "Installing base packages"
|
||||||
|
apt-get update -y
|
||||||
|
apt-get install -y ca-certificates curl gnupg locales postgresql postgresql-contrib
|
||||||
|
sed -i 's/^# *en_US.UTF-8 UTF-8/en_US.UTF-8 UTF-8/' /etc/locale.gen 2>/dev/null || true
|
||||||
|
locale-gen en_US.UTF-8 >/dev/null 2>&1 || true
|
||||||
|
update-locale LANG=en_US.UTF-8 >/dev/null 2>&1 || true
|
||||||
|
}
|
||||||
|
|
||||||
|
install_aspnet_runtime() {
|
||||||
|
if command -v dotnet >/dev/null 2>&1 && dotnet --list-runtimes 2>/dev/null | grep -q 'Microsoft.AspNetCore.App 10\.'; then
|
||||||
|
msg "ASP.NET Core 10 runtime already installed"
|
||||||
|
return 0
|
||||||
|
fi
|
||||||
|
msg "Installing ASP.NET Core 10 runtime"
|
||||||
|
curl -fsSL https://packages.microsoft.com/config/debian/12/packages-microsoft-prod.deb -o /tmp/packages-microsoft-prod.deb
|
||||||
|
dpkg -i /tmp/packages-microsoft-prod.deb
|
||||||
|
apt-get update -y
|
||||||
|
apt-get install -y aspnetcore-runtime-10.0
|
||||||
|
}
|
||||||
|
|
||||||
|
setup_postgres() {
|
||||||
|
msg "Configuring PostgreSQL (UTF8 — required for Npgsql ICU collations)"
|
||||||
|
systemctl enable --now postgresql
|
||||||
|
|
||||||
|
# Ensure locale exists before CREATE DATABASE … LC_COLLATE
|
||||||
|
sed -i 's/^# *en_US.UTF-8 UTF-8/en_US.UTF-8 UTF-8/' /etc/locale.gen 2>/dev/null || true
|
||||||
|
locale-gen en_US.UTF-8 >/dev/null 2>&1 || true
|
||||||
|
|
||||||
|
if ! as_postgres psql -tAc "SELECT 1 FROM pg_roles WHERE rolname='${PG_USER}'" | grep -q 1; then
|
||||||
|
as_postgres psql -c "CREATE USER ${PG_USER} WITH PASSWORD '${PG_PASS}';"
|
||||||
|
else
|
||||||
|
as_postgres psql -c "ALTER USER ${PG_USER} WITH PASSWORD '${PG_PASS}';"
|
||||||
|
fi
|
||||||
|
|
||||||
|
local enc
|
||||||
|
enc=$(as_postgres psql -tAc "SELECT pg_encoding_to_char(encoding) FROM pg_database WHERE datname='${PG_DB}'" 2>/dev/null | tr -d '[:space:]' || true)
|
||||||
|
if [[ -n "$enc" && "$enc" != "UTF8" ]]; then
|
||||||
|
msg "Recreating ${PG_DB}: encoding was ${enc}, need UTF8"
|
||||||
|
as_postgres psql -c "SELECT pg_terminate_backend(pid) FROM pg_stat_activity WHERE datname='${PG_DB}' AND pid <> pg_backend_pid();" || true
|
||||||
|
as_postgres psql -c "DROP DATABASE IF EXISTS ${PG_DB};"
|
||||||
|
enc=""
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [[ -z "$enc" ]]; then
|
||||||
|
as_postgres psql -c "CREATE DATABASE ${PG_DB} OWNER ${PG_USER} ENCODING 'UTF8' LC_COLLATE 'en_US.UTF-8' LC_CTYPE 'en_US.UTF-8' TEMPLATE template0;" \
|
||||||
|
|| as_postgres psql -c "CREATE DATABASE ${PG_DB} OWNER ${PG_USER} ENCODING 'UTF8' LC_COLLATE 'C.UTF-8' LC_CTYPE 'C.UTF-8' TEMPLATE template0;"
|
||||||
|
fi
|
||||||
|
|
||||||
|
as_postgres psql -c "GRANT ALL PRIVILEGES ON DATABASE ${PG_DB} TO ${PG_USER};"
|
||||||
|
as_postgres psql -d "$PG_DB" -c "GRANT ALL ON SCHEMA public TO ${PG_USER};" || true
|
||||||
|
enc=$(as_postgres psql -tAc "SELECT pg_encoding_to_char(encoding) FROM pg_database WHERE datname='${PG_DB}'" | tr -d '[:space:]')
|
||||||
|
msg "PostgreSQL ready (db/user/pass=${PG_DB}, encoding=${enc})"
|
||||||
|
}
|
||||||
|
|
||||||
|
write_runtime_config() {
|
||||||
|
mkdir -p "$API_DIR"
|
||||||
|
if [[ ! -f "$API_DIR/appsettings.Production.json" ]]; then
|
||||||
|
cat >"$API_DIR/appsettings.Production.json" <<EOF
|
||||||
|
{
|
||||||
|
"DatabaseProvider": "npgsql",
|
||||||
|
"Kestrel": {
|
||||||
|
"EndPoints": {
|
||||||
|
"Http": {
|
||||||
|
"Url": "http://*:${API_PORT}"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"FrontEnd": {
|
||||||
|
"Host": "${PUBLIC_URL}"
|
||||||
|
},
|
||||||
|
"Cors": {
|
||||||
|
"AllowedOrigins": [
|
||||||
|
"${PUBLIC_URL}"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"OpenIddict": {
|
||||||
|
"UseEphemeralKeys": true,
|
||||||
|
"AllowHttp": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
EOF
|
||||||
|
fi
|
||||||
|
if [[ ! -f "$API_DIR/appsettings.shared.Production.json" ]]; then
|
||||||
|
cat >"$API_DIR/appsettings.shared.Production.json" <<EOF
|
||||||
|
{
|
||||||
|
"ConnectionStrings": {
|
||||||
|
"npgsql": "Host=127.0.0.1;Port=5432;Database=${PG_DB};Username=${PG_USER};Password=${PG_PASS}"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
EOF
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
fetch_app() {
|
||||||
|
if [[ "$SKIP_FETCH" == "1" ]]; then
|
||||||
|
msg "SKIP_FETCH=1 — not downloading release"
|
||||||
|
return 0
|
||||||
|
fi
|
||||||
|
local fetch_script="${ASSETS_DIR}/fetch-release.sh"
|
||||||
|
if [[ ! -f "$fetch_script" ]]; then
|
||||||
|
fetch_script="${APP_DIR}/proxmox/fetch-release.sh"
|
||||||
|
fi
|
||||||
|
if [[ ! -f "$fetch_script" ]]; then
|
||||||
|
echo "Missing fetch-release.sh (looked in ASSETS_DIR and ${APP_DIR}/proxmox)." >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
# shellcheck disable=SC1090
|
||||||
|
source "$fetch_script"
|
||||||
|
download_and_extract
|
||||||
|
}
|
||||||
|
|
||||||
|
install_systemd() {
|
||||||
|
msg "Configuring systemd unit myoffice-api"
|
||||||
|
local unit_src="${ASSETS_DIR}/myoffice-api.service"
|
||||||
|
if [[ ! -f "$unit_src" ]]; then
|
||||||
|
unit_src="${APP_DIR}/proxmox/myoffice-api.service"
|
||||||
|
fi
|
||||||
|
if [[ ! -f "$unit_src" ]]; then
|
||||||
|
echo "Missing myoffice-api.service" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
sed -e "s|__API_DIR__|${API_DIR}|g" -e "s|__API_PORT__|${API_PORT}|g" \
|
||||||
|
"$unit_src" >/etc/systemd/system/myoffice-api.service
|
||||||
|
|
||||||
|
mkdir -p "${APP_DIR}/proxmox"
|
||||||
|
if [[ -d "$ASSETS_DIR" ]]; then
|
||||||
|
cp -a "${ASSETS_DIR}/." "${APP_DIR}/proxmox/"
|
||||||
|
fi
|
||||||
|
chmod +x "${APP_DIR}/proxmox/"*.sh 2>/dev/null || true
|
||||||
|
|
||||||
|
cat >"${APP_DIR}/proxmox/env" <<EOF
|
||||||
|
PUBLIC_URL=${PUBLIC_URL}
|
||||||
|
API_PORT=${API_PORT}
|
||||||
|
APP_DIR=${APP_DIR}
|
||||||
|
GITEA_URL=${GITEA_URL:-}
|
||||||
|
GITEA_OWNER=${GITEA_OWNER:-}
|
||||||
|
GITEA_REPO=${GITEA_REPO:-myoffice_public}
|
||||||
|
RELEASE_TAG=${RELEASE_TAG:-latest}
|
||||||
|
RELEASE_ASSET_URL=${RELEASE_ASSET_URL:-}
|
||||||
|
GITEA_HOSTS_IP=${GITEA_HOSTS_IP:-}
|
||||||
|
GITEA_HOSTS_NAME=${GITEA_HOSTS_NAME:-mygit.ase.com.ua}
|
||||||
|
EOF
|
||||||
|
|
||||||
|
# hosts already applied at start via ensure_gitea_hosts when GITEA_HOSTS_IP is set
|
||||||
|
|
||||||
|
systemctl daemon-reload
|
||||||
|
systemctl enable myoffice-api
|
||||||
|
if [[ -f "${API_DIR}/MyOffice.Web.dll" ]]; then
|
||||||
|
systemctl restart myoffice-api
|
||||||
|
else
|
||||||
|
systemctl stop myoffice-api 2>/dev/null || true
|
||||||
|
msg "App DLL missing — service not started"
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
print_done() {
|
||||||
|
echo
|
||||||
|
echo " MyOffice CT is ready:"
|
||||||
|
echo " Public URL ${PUBLIC_URL}"
|
||||||
|
echo " Listen 0.0.0.0:${API_PORT} (proxy this from your nginx LXC)"
|
||||||
|
echo " Postgres 127.0.0.1:5432 db/user/pass=${PG_DB}"
|
||||||
|
echo " App dir ${API_DIR}"
|
||||||
|
echo
|
||||||
|
echo " Update later: ${APP_DIR}/proxmox/update.sh"
|
||||||
|
echo " Demo login: user_UAH@user_UAH.userUAH / user_UAH"
|
||||||
|
echo
|
||||||
|
}
|
||||||
|
|
||||||
|
detect_public_url
|
||||||
|
ensure_gitea_hosts
|
||||||
|
install_base
|
||||||
|
install_aspnet_runtime
|
||||||
|
setup_postgres
|
||||||
|
write_runtime_config
|
||||||
|
fetch_app
|
||||||
|
install_systemd
|
||||||
|
print_done
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
[Unit]
|
||||||
|
Description=MyOffice ASP.NET Core (API + SPA)
|
||||||
|
After=network.target postgresql.service
|
||||||
|
Wants=postgresql.service
|
||||||
|
|
||||||
|
[Service]
|
||||||
|
WorkingDirectory=__API_DIR__
|
||||||
|
ExecStart=/usr/bin/dotnet __API_DIR__/MyOffice.Web.dll
|
||||||
|
Restart=always
|
||||||
|
RestartSec=5
|
||||||
|
KillSignal=SIGINT
|
||||||
|
SyslogIdentifier=myoffice-api
|
||||||
|
User=root
|
||||||
|
Environment=ASPNETCORE_ENVIRONMENT=Production
|
||||||
|
Environment=DOTNET_PRINT_TELEMETRY_MESSAGE=false
|
||||||
|
Environment=ASPNETCORE_URLS=http://*:__API_PORT__
|
||||||
|
|
||||||
|
[Install]
|
||||||
|
WantedBy=multi-user.target
|
||||||
@@ -0,0 +1,565 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# MyOffice Proxmox LXC installer (community-scripts style).
|
||||||
|
#
|
||||||
|
# One-liner (optional arg = IP for CT /etc/hosts → mygit hostname, when hairpin DNS fails):
|
||||||
|
# bash -c "$(curl -fsSL https://mygit.ase.com.ua/alexandr/myoffice_public/raw/branch/master/proxmox/myoffice.sh)" 192.168.100.10
|
||||||
|
# Or: curl -fsSL …/myoffice.sh | bash -s -- 192.168.100.10
|
||||||
|
# Or: export GITEA_HOSTS_IP=192.168.100.10 then run without arg
|
||||||
|
#
|
||||||
|
# Creates one LXC with PostgreSQL + ASP.NET runtime + app from Gitea Release.
|
||||||
|
# No Docker, no nginx, no git/SDK/Node in the CT (use your existing proxy LXC in front).
|
||||||
|
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
GITEA_URL="${GITEA_URL:-https://mygit.ase.com.ua}"
|
||||||
|
GITEA_OWNER="${GITEA_OWNER:-alexandr}"
|
||||||
|
GITEA_REPO="${GITEA_REPO:-myoffice_public}"
|
||||||
|
GITEA_BRANCH="${GITEA_BRANCH:-master}"
|
||||||
|
RELEASE_TAG="${RELEASE_TAG:-latest}"
|
||||||
|
RELEASE_ASSET_URL="${RELEASE_ASSET_URL:-}"
|
||||||
|
GITEA_TOKEN="${GITEA_TOKEN:-}"
|
||||||
|
GITEA_HOSTS_NAME="${GITEA_HOSTS_NAME:-mygit.ase.com.ua}"
|
||||||
|
|
||||||
|
# Optional CT hosts override: env, or positional IP.
|
||||||
|
# bash -c "…" IP → IP is $0 ; bash -s -- IP → IP is $1
|
||||||
|
is_ipv4() { [[ "${1:-}" =~ ^([0-9]{1,3}\.){3}[0-9]{1,3}$ ]]; }
|
||||||
|
if [[ -z "${GITEA_HOSTS_IP:-}" ]]; then
|
||||||
|
if is_ipv4 "${1:-}"; then
|
||||||
|
GITEA_HOSTS_IP="$1"
|
||||||
|
elif is_ipv4 "${0:-}"; then
|
||||||
|
GITEA_HOSTS_IP="$0"
|
||||||
|
else
|
||||||
|
GITEA_HOSTS_IP=""
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [[ -z "${REPO_RAW_BASE:-}" ]]; then
|
||||||
|
REPO_RAW_BASE="${GITEA_URL%/}/${GITEA_OWNER}/${GITEA_REPO}/raw/branch/${GITEA_BRANCH}"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Derive GITEA_* from REPO_RAW_BASE when only raw base is set:
|
||||||
|
# https://host/owner/repo/raw/branch/master
|
||||||
|
if [[ "$REPO_RAW_BASE" =~ ^(https?://[^/]+)/([^/]+)/([^/]+)/raw/branch/([^/]+) ]]; then
|
||||||
|
GITEA_URL="${BASH_REMATCH[1]}"
|
||||||
|
GITEA_OWNER="${BASH_REMATCH[2]}"
|
||||||
|
GITEA_REPO="${BASH_REMATCH[3]}"
|
||||||
|
GITEA_BRANCH="${BASH_REMATCH[4]}"
|
||||||
|
elif [[ "$REPO_RAW_BASE" =~ ^(https?://[^/]+)/([^/]+)/([^/]+)/raw/ ]]; then
|
||||||
|
GITEA_URL="${BASH_REMATCH[1]}"
|
||||||
|
GITEA_OWNER="${BASH_REMATCH[2]}"
|
||||||
|
GITEA_REPO="${BASH_REMATCH[3]}"
|
||||||
|
fi
|
||||||
|
|
||||||
|
APP="MyOffice"
|
||||||
|
APP_DIR="/opt/myoffice"
|
||||||
|
|
||||||
|
YW=$'\033[33m'
|
||||||
|
BL=$'\033[36m'
|
||||||
|
RD=$'\033[01;31m'
|
||||||
|
BGN=$'\033[4;92m'
|
||||||
|
GN=$'\033[1;92m'
|
||||||
|
DGN=$'\033[32m'
|
||||||
|
CL=$'\033[m'
|
||||||
|
BOLD=$'\033[1m'
|
||||||
|
BFR=$'\r\033[K'
|
||||||
|
TAB=$' '
|
||||||
|
|
||||||
|
CM="${TAB}✔️${TAB}${CL}"
|
||||||
|
CROSS="${TAB}✖️${TAB}${CL}"
|
||||||
|
INFO="${TAB}💡${TAB}${CL}"
|
||||||
|
OS="${TAB}🖥️${TAB}${CL}"
|
||||||
|
CONTAINERTYPE="${TAB}📦${TAB}${CL}"
|
||||||
|
DISKSIZE="${TAB}💾${TAB}${CL}"
|
||||||
|
CPUCORE="${TAB}🧠${TAB}${CL}"
|
||||||
|
RAMSIZE="${TAB}🛠️${TAB}${CL}"
|
||||||
|
CONTAINERID="${TAB}🆔${TAB}${CL}"
|
||||||
|
HOSTNAME="${TAB}🏠${TAB}${CL}"
|
||||||
|
BRIDGE="${TAB}🌉${TAB}${CL}"
|
||||||
|
GATEWAY="${TAB}🌐${TAB}${CL}"
|
||||||
|
DEFAULT="${TAB}⚙️${TAB}${CL}"
|
||||||
|
CREATING="${TAB}🚀${TAB}${CL}"
|
||||||
|
ADVANCED="${TAB}🧩${TAB}${CL}"
|
||||||
|
|
||||||
|
header_info() {
|
||||||
|
clear
|
||||||
|
cat <<"EOF"
|
||||||
|
__ ___ ____ ____________
|
||||||
|
/ |/ /_ __/ __ \/ __/ __/ _/______
|
||||||
|
/ /|_/ / / / / / / / /_/ /_ / // ___/ _ \
|
||||||
|
/ / / / /_/ / /_/ / __/ __// // /__/ __/
|
||||||
|
/_/ /_/\__, /\____/_/ /_/ /___/\___/\___/
|
||||||
|
/____/ Proxmox LXC (Gitea release)
|
||||||
|
EOF
|
||||||
|
}
|
||||||
|
|
||||||
|
msg_info() { echo -ne "${TAB}${YW}${1}${CL}"; }
|
||||||
|
msg_ok() { echo -e "${BFR}${CM}${GN}${1}${CL}"; }
|
||||||
|
msg_error() { echo -e "${BFR}${CROSS}${RD}${1}${CL}"; }
|
||||||
|
|
||||||
|
error_handler() {
|
||||||
|
local exit_code=$?
|
||||||
|
local line_number=$1
|
||||||
|
local command=$2
|
||||||
|
echo -e "\n${RD}[ERROR]${CL} line ${RD}${line_number}${CL}: exit ${RD}${exit_code}${CL}: ${YW}${command}${CL}\n"
|
||||||
|
if [[ -n "${CTID:-}" ]] && pct status "$CTID" &>/dev/null; then
|
||||||
|
echo -e "${INFO}CT ${CTID} exists; destroy with: pct stop ${CTID}; pct destroy ${CTID}"
|
||||||
|
fi
|
||||||
|
exit "$exit_code"
|
||||||
|
}
|
||||||
|
trap 'error_handler $LINENO "$BASH_COMMAND"' ERR
|
||||||
|
|
||||||
|
exit_script() {
|
||||||
|
clear
|
||||||
|
echo -e "\n${CROSS}${RD}User exited script${CL}\n"
|
||||||
|
exit 0
|
||||||
|
}
|
||||||
|
|
||||||
|
check_root() {
|
||||||
|
if [[ "$(id -u)" -ne 0 ]]; then
|
||||||
|
msg_error "Please run this script as root on the Proxmox host."
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
pve_check() {
|
||||||
|
if ! command -v pveversion >/dev/null 2>&1; then
|
||||||
|
msg_error "This script must run on a Proxmox VE host (pveversion not found)."
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
if ! command -v whiptail >/dev/null 2>&1; then
|
||||||
|
msg_error "whiptail is required (apt install whiptail)."
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
arch_check() {
|
||||||
|
if [[ "$(dpkg --print-architecture)" != "amd64" ]]; then
|
||||||
|
msg_error "Only amd64 is supported."
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
get_valid_nextid() {
|
||||||
|
local try_id
|
||||||
|
try_id=$(pvesh get /cluster/nextid)
|
||||||
|
while true; do
|
||||||
|
if [[ -f "/etc/pve/qemu-server/${try_id}.conf" ]] || [[ -f "/etc/pve/lxc/${try_id}.conf" ]]; then
|
||||||
|
try_id=$((try_id + 1))
|
||||||
|
continue
|
||||||
|
fi
|
||||||
|
break
|
||||||
|
done
|
||||||
|
echo "$try_id"
|
||||||
|
}
|
||||||
|
|
||||||
|
select_storage() {
|
||||||
|
local STORAGE_MENU=()
|
||||||
|
local MSG_MAX_LENGTH=0
|
||||||
|
local line TAG TYPE FREE ITEM OFFSET VALID
|
||||||
|
while read -r line; do
|
||||||
|
TAG=$(echo "$line" | awk '{print $1}')
|
||||||
|
TYPE=$(echo "$line" | awk '{printf "%-10s", $2}')
|
||||||
|
FREE=$(echo "$line" | numfmt --field 4-6 --from-unit=K --to=iec --format %.2f 2>/dev/null | awk '{printf("%9sB", $6)}' || echo "?")
|
||||||
|
ITEM=" Type: $TYPE Free: $FREE "
|
||||||
|
OFFSET=2
|
||||||
|
if [[ $((${#ITEM} + OFFSET)) -gt ${MSG_MAX_LENGTH:-0} ]]; then
|
||||||
|
MSG_MAX_LENGTH=$((${#ITEM} + OFFSET))
|
||||||
|
fi
|
||||||
|
STORAGE_MENU+=("$TAG" "$ITEM" "OFF")
|
||||||
|
done < <(pvesm status -content rootdir | awk 'NR>1')
|
||||||
|
|
||||||
|
VALID=$(pvesm status -content rootdir | awk 'NR>1')
|
||||||
|
if [[ -z "$VALID" ]]; then
|
||||||
|
msg_error "Unable to detect a valid storage location (rootdir)."
|
||||||
|
exit 1
|
||||||
|
elif [[ $((${#STORAGE_MENU[@]} / 3)) -eq 1 ]]; then
|
||||||
|
STORAGE=${STORAGE_MENU[0]}
|
||||||
|
else
|
||||||
|
while [[ -z "${STORAGE:+x}" ]]; do
|
||||||
|
STORAGE=$(whiptail --backtitle "MyOffice Proxmox" --title "Storage Pools" --radiolist \
|
||||||
|
"Which storage pool for ${HN}?\n(Spacebar to select)\n" \
|
||||||
|
16 $((MSG_MAX_LENGTH + 23)) 6 \
|
||||||
|
"${STORAGE_MENU[@]}" 3>&1 1>&2 2>&3) || exit_script
|
||||||
|
done
|
||||||
|
fi
|
||||||
|
msg_ok "Using ${CL}${BL}${STORAGE}${CL}${GN} for storage."
|
||||||
|
}
|
||||||
|
|
||||||
|
ask_ip_cpu_ram_disk() {
|
||||||
|
# Always prompt: IP, CPU (default 2), RAM MiB (default 2048), disk GiB (default 10).
|
||||||
|
while true; do
|
||||||
|
if CORE_COUNT=$(whiptail --backtitle "MyOffice Proxmox" --inputbox "CPU cores" 8 58 "${CORE_COUNT:-2}" --title "CPU" --cancel-button Exit-Script 3>&1 1>&2 2>&3); then
|
||||||
|
[[ -z "$CORE_COUNT" ]] && CORE_COUNT="2"
|
||||||
|
if [[ "$CORE_COUNT" =~ ^[1-9][0-9]*$ ]]; then
|
||||||
|
echo -e "${CPUCORE}${BOLD}${DGN}CPU Cores: ${BGN}${CORE_COUNT}${CL}"
|
||||||
|
break
|
||||||
|
fi
|
||||||
|
whiptail --backtitle "MyOffice Proxmox" --title "INVALID" --msgbox "CPU cores must be a positive integer." 8 58
|
||||||
|
else
|
||||||
|
exit_script
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
|
||||||
|
while true; do
|
||||||
|
if RAM_SIZE=$(whiptail --backtitle "MyOffice Proxmox" --inputbox "RAM in MiB" 8 58 "${RAM_SIZE:-2048}" --title "MEMORY" --cancel-button Exit-Script 3>&1 1>&2 2>&3); then
|
||||||
|
[[ -z "$RAM_SIZE" ]] && RAM_SIZE="2048"
|
||||||
|
if [[ "$RAM_SIZE" =~ ^[1-9][0-9]*$ ]]; then
|
||||||
|
echo -e "${RAMSIZE}${BOLD}${DGN}RAM: ${BGN}${RAM_SIZE} MiB${CL}"
|
||||||
|
break
|
||||||
|
fi
|
||||||
|
whiptail --backtitle "MyOffice Proxmox" --title "INVALID" --msgbox "RAM must be a positive integer (MiB)." 8 58
|
||||||
|
else
|
||||||
|
exit_script
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
|
||||||
|
while true; do
|
||||||
|
if DISK_SIZE=$(whiptail --backtitle "MyOffice Proxmox" --inputbox "Disk size in GiB" 8 58 "${DISK_SIZE:-10}" --title "DISK" --cancel-button Exit-Script 3>&1 1>&2 2>&3); then
|
||||||
|
DISK_SIZE=$(echo "${DISK_SIZE:-10}" | tr -d ' Gg')
|
||||||
|
[[ -z "$DISK_SIZE" ]] && DISK_SIZE="10"
|
||||||
|
if [[ "$DISK_SIZE" =~ ^[1-9][0-9]*$ ]]; then
|
||||||
|
echo -e "${DISKSIZE}${BOLD}${DGN}Disk: ${BGN}${DISK_SIZE}G${CL}"
|
||||||
|
break
|
||||||
|
fi
|
||||||
|
whiptail --backtitle "MyOffice Proxmox" --title "INVALID" --msgbox "Disk must be a positive integer (GiB)." 8 58
|
||||||
|
else
|
||||||
|
exit_script
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
|
||||||
|
if whiptail --backtitle "MyOffice Proxmox" --title "NETWORK" --yesno "Use DHCP for IP?\n\nYes = DHCP\nNo = static IP" --yes-button DHCP --no-button Static 12 58; then
|
||||||
|
NET="dhcp"
|
||||||
|
GATEWAY_IP=""
|
||||||
|
echo -e "${GATEWAY}${BOLD}${DGN}IP: ${BGN}DHCP${CL}"
|
||||||
|
else
|
||||||
|
while true; do
|
||||||
|
if NET=$(whiptail --backtitle "MyOffice Proxmox" --inputbox "Static IP with CIDR\n(e.g. 192.168.1.120/24)" 10 58 "${NET:-}" --title "IP ADDRESS" --cancel-button Exit-Script 3>&1 1>&2 2>&3); then
|
||||||
|
if [[ -n "$NET" && "$NET" == */* ]]; then
|
||||||
|
break
|
||||||
|
fi
|
||||||
|
whiptail --backtitle "MyOffice Proxmox" --title "INVALID" --msgbox "Enter IP/CIDR, e.g. 192.168.1.120/24" 8 58
|
||||||
|
else
|
||||||
|
exit_script
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
while true; do
|
||||||
|
if GATEWAY_IP=$(whiptail --backtitle "MyOffice Proxmox" --inputbox "Gateway\n(e.g. 192.168.1.1)" 10 58 "${GATEWAY_IP:-}" --title "GATEWAY" --cancel-button Exit-Script 3>&1 1>&2 2>&3); then
|
||||||
|
if [[ -n "$GATEWAY_IP" ]]; then
|
||||||
|
break
|
||||||
|
fi
|
||||||
|
whiptail --backtitle "MyOffice Proxmox" --title "INVALID" --msgbox "Gateway is required for static IP." 8 58
|
||||||
|
else
|
||||||
|
exit_script
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
echo -e "${GATEWAY}${BOLD}${DGN}IP: ${BGN}${NET} gw ${GATEWAY_IP}${CL}"
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
default_settings() {
|
||||||
|
CTID=$(get_valid_nextid)
|
||||||
|
HN="myoffice"
|
||||||
|
CORE_COUNT="2"
|
||||||
|
RAM_SIZE="2048"
|
||||||
|
DISK_SIZE="10"
|
||||||
|
BRG="vmbr0"
|
||||||
|
NET="dhcp"
|
||||||
|
GATEWAY_IP=""
|
||||||
|
API_PORT="9100"
|
||||||
|
PUBLIC_URL="" # filled after CT IP known
|
||||||
|
START_CT="yes"
|
||||||
|
METHOD="default"
|
||||||
|
echo -e "${CONTAINERID}${BOLD}${DGN}Container ID: ${BGN}${CTID}${CL}"
|
||||||
|
echo -e "${HOSTNAME}${BOLD}${DGN}Hostname: ${BGN}${HN}${CL}"
|
||||||
|
echo -e "${OS}${BOLD}${DGN}OS: ${BGN}Debian 12${CL}"
|
||||||
|
ask_ip_cpu_ram_disk
|
||||||
|
echo -e "${BRIDGE}${BOLD}${DGN}Bridge: ${BGN}${BRG}${CL}"
|
||||||
|
echo -e "${DEFAULT}${BOLD}${DGN}API listen: ${BGN}:${API_PORT}${CL} (SPA from release wwwroot)"
|
||||||
|
echo -e "${CONTAINERTYPE}${BOLD}${DGN}Stack: ${BGN}PostgreSQL + ASP.NET + Gitea release${CL}"
|
||||||
|
echo -e "${CREATING}${BOLD}${DGN}Creating MyOffice LXC${CL}"
|
||||||
|
}
|
||||||
|
|
||||||
|
advanced_settings() {
|
||||||
|
METHOD="advanced"
|
||||||
|
[[ -z "${CTID:-}" ]] && CTID=$(get_valid_nextid)
|
||||||
|
CORE_COUNT="${CORE_COUNT:-2}"
|
||||||
|
RAM_SIZE="${RAM_SIZE:-2048}"
|
||||||
|
DISK_SIZE="${DISK_SIZE:-10}"
|
||||||
|
NET="${NET:-dhcp}"
|
||||||
|
GATEWAY_IP="${GATEWAY_IP:-}"
|
||||||
|
|
||||||
|
while true; do
|
||||||
|
if CTID=$(whiptail --backtitle "MyOffice Proxmox" --inputbox "Set Container ID" 8 58 "$CTID" --title "CONTAINER ID" --cancel-button Exit-Script 3>&1 1>&2 2>&3); then
|
||||||
|
[[ -z "$CTID" ]] && CTID=$(get_valid_nextid)
|
||||||
|
if pct status "$CTID" &>/dev/null || qm status "$CTID" &>/dev/null; then
|
||||||
|
echo -e "${CROSS}${RD}ID $CTID is already in use${CL}"
|
||||||
|
sleep 2
|
||||||
|
continue
|
||||||
|
fi
|
||||||
|
echo -e "${CONTAINERID}${BOLD}${DGN}Container ID: ${BGN}${CTID}${CL}"
|
||||||
|
break
|
||||||
|
else
|
||||||
|
exit_script
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
|
||||||
|
if HN_IN=$(whiptail --backtitle "MyOffice Proxmox" --inputbox "Set Hostname" 8 58 myoffice --title "HOSTNAME" --cancel-button Exit-Script 3>&1 1>&2 2>&3); then
|
||||||
|
HN=$(echo "${HN_IN:-myoffice}" | tr '[:upper:]' '[:lower:]' | tr -cs 'a-z0-9-' '-' | sed 's/^-//;s/-$//' || true)
|
||||||
|
[[ -z "$HN" ]] && HN="myoffice"
|
||||||
|
echo -e "${HOSTNAME}${BOLD}${DGN}Hostname: ${BGN}${HN}${CL}"
|
||||||
|
else
|
||||||
|
exit_script
|
||||||
|
fi
|
||||||
|
|
||||||
|
ask_ip_cpu_ram_disk
|
||||||
|
|
||||||
|
if BRG=$(whiptail --backtitle "MyOffice Proxmox" --inputbox "Bridge" 8 58 vmbr0 --title "BRIDGE" --cancel-button Exit-Script 3>&1 1>&2 2>&3); then
|
||||||
|
[[ -z "$BRG" ]] && BRG="vmbr0"
|
||||||
|
echo -e "${BRIDGE}${BOLD}${DGN}Bridge: ${BGN}${BRG}${CL}"
|
||||||
|
else
|
||||||
|
exit_script
|
||||||
|
fi
|
||||||
|
|
||||||
|
if API_PORT=$(whiptail --backtitle "MyOffice Proxmox" --inputbox "Kestrel listen port" 8 58 9100 --title "API PORT" --cancel-button Exit-Script 3>&1 1>&2 2>&3); then
|
||||||
|
[[ -z "$API_PORT" ]] && API_PORT="9100"
|
||||||
|
else
|
||||||
|
exit_script
|
||||||
|
fi
|
||||||
|
echo -e "${DEFAULT}${BOLD}${DGN}API listen: ${BGN}:${API_PORT}${CL}"
|
||||||
|
|
||||||
|
if PUBLIC_URL=$(whiptail --backtitle "MyOffice Proxmox" --inputbox "Browser public URL (empty = http://CT_IP:PORT). Use your nginx proxy URL if any." 10 70 "" --title "PUBLIC URL" --cancel-button Exit-Script 3>&1 1>&2 2>&3); then
|
||||||
|
PUBLIC_URL=$(echo "$PUBLIC_URL" | sed 's:/*$::')
|
||||||
|
else
|
||||||
|
exit_script
|
||||||
|
fi
|
||||||
|
|
||||||
|
if RELEASE_TAG=$(whiptail --backtitle "MyOffice Proxmox" --inputbox "Gitea release tag (latest or v1.2.3)" 8 58 "${RELEASE_TAG}" --title "RELEASE TAG" --cancel-button Exit-Script 3>&1 1>&2 2>&3); then
|
||||||
|
[[ -z "$RELEASE_TAG" ]] && RELEASE_TAG="latest"
|
||||||
|
else
|
||||||
|
exit_script
|
||||||
|
fi
|
||||||
|
echo -e "${DEFAULT}${BOLD}${DGN}Release: ${BGN}${RELEASE_TAG}${CL}"
|
||||||
|
|
||||||
|
if whiptail --backtitle "MyOffice Proxmox" --title "START CONTAINER" --yesno "Start CT when created?" 10 58; then
|
||||||
|
START_CT="yes"
|
||||||
|
else
|
||||||
|
START_CT="no"
|
||||||
|
fi
|
||||||
|
|
||||||
|
if whiptail --backtitle "MyOffice Proxmox" --title "ADVANCED SETTINGS" --yesno "Ready to create MyOffice LXC?" --no-button Do-Over 10 58; then
|
||||||
|
echo -e "${CREATING}${BOLD}${DGN}Creating MyOffice LXC with advanced settings${CL}"
|
||||||
|
else
|
||||||
|
header_info
|
||||||
|
echo -e "${ADVANCED}${BOLD}${RD}Using Advanced Settings${CL}"
|
||||||
|
advanced_settings
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
start_script() {
|
||||||
|
if whiptail --backtitle "MyOffice Proxmox" --title "SETTINGS" --yesno "Use Default Settings?" --no-button Advanced 10 58; then
|
||||||
|
header_info
|
||||||
|
echo -e "${BL}Using Default Settings${CL}"
|
||||||
|
default_settings
|
||||||
|
else
|
||||||
|
header_info
|
||||||
|
echo -e "${RD}Using Advanced Settings${CL}"
|
||||||
|
advanced_settings
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
ensure_template() {
|
||||||
|
local template="debian-12-standard"
|
||||||
|
local store="local"
|
||||||
|
msg_info "Ensuring Debian 12 LXC template..."
|
||||||
|
if ! pveam list "$store" 2>/dev/null | grep -q "$template"; then
|
||||||
|
pveam update >/dev/null
|
||||||
|
local remote
|
||||||
|
remote=$(pveam available -section system | awk '/debian-12-standard/ {print $2; exit}')
|
||||||
|
if [[ -z "$remote" ]]; then
|
||||||
|
msg_error "Could not find debian-12-standard template in pveam available."
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
pveam download "$store" "$remote"
|
||||||
|
fi
|
||||||
|
TEMPLATE_REF=$(pveam list "$store" | awk '/debian-12-standard/ {print $1; exit}')
|
||||||
|
if [[ -z "${TEMPLATE_REF:-}" ]]; then
|
||||||
|
msg_error "Debian 12 template not found after download."
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
msg_ok "Template ${CL}${BL}${TEMPLATE_REF}${CL}"
|
||||||
|
}
|
||||||
|
|
||||||
|
create_container() {
|
||||||
|
local net_cfg
|
||||||
|
if [[ "$NET" == "dhcp" ]]; then
|
||||||
|
net_cfg="name=eth0,bridge=${BRG},ip=dhcp"
|
||||||
|
else
|
||||||
|
net_cfg="name=eth0,bridge=${BRG},ip=${NET},gw=${GATEWAY_IP}"
|
||||||
|
fi
|
||||||
|
|
||||||
|
msg_info "Creating LXC ${CTID} (${HN})..."
|
||||||
|
pct create "$CTID" "$TEMPLATE_REF" \
|
||||||
|
--hostname "$HN" \
|
||||||
|
--cores "$CORE_COUNT" \
|
||||||
|
--memory "$RAM_SIZE" \
|
||||||
|
--swap 512 \
|
||||||
|
--rootfs "${STORAGE}:${DISK_SIZE}" \
|
||||||
|
--net0 "$net_cfg" \
|
||||||
|
--unprivileged 1 \
|
||||||
|
--ostype debian \
|
||||||
|
--onboot 1 \
|
||||||
|
--timezone host \
|
||||||
|
--tags "myoffice" \
|
||||||
|
--description "MyOffice: PostgreSQL + ASP.NET Core from Gitea release. Proxy via external nginx LXC."
|
||||||
|
msg_ok "Created CT ${CL}${BL}${CTID}${CL}"
|
||||||
|
|
||||||
|
if [[ "$START_CT" == "yes" ]]; then
|
||||||
|
msg_info "Starting CT ${CTID}..."
|
||||||
|
pct start "$CTID"
|
||||||
|
msg_ok "Started CT ${CTID}"
|
||||||
|
else
|
||||||
|
msg_error "CT created but not started (Start=no). Start with: pct start ${CTID}"
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
wait_for_network() {
|
||||||
|
msg_info "Waiting for CT network..."
|
||||||
|
local i ip
|
||||||
|
for i in $(seq 1 60); do
|
||||||
|
ip=$(pct exec "$CTID" -- bash -c "ip -4 -o addr show eth0 2>/dev/null | awk '{print \$4}' | cut -d/ -f1 | head -1" 2>/dev/null || true)
|
||||||
|
if [[ -n "$ip" ]]; then
|
||||||
|
CT_IP="$ip"
|
||||||
|
msg_ok "CT IP ${CL}${BL}${CT_IP}${CL}"
|
||||||
|
return 0
|
||||||
|
fi
|
||||||
|
sleep 2
|
||||||
|
done
|
||||||
|
msg_error "Timed out waiting for CT IP."
|
||||||
|
exit 1
|
||||||
|
}
|
||||||
|
|
||||||
|
# Optional CT /etc/hosts: GITEA_HOSTS_IP → GITEA_HOSTS_NAME (skip if empty).
|
||||||
|
ensure_ct_gitea_hosts() {
|
||||||
|
if [[ -z "${GITEA_HOSTS_IP}" ]]; then
|
||||||
|
return 0
|
||||||
|
fi
|
||||||
|
msg_info "CT /etc/hosts: ${GITEA_HOSTS_IP} ${GITEA_HOSTS_NAME}"
|
||||||
|
pct exec "$CTID" -- bash -c "
|
||||||
|
sed -i '/[[:space:]]${GITEA_HOSTS_NAME}\$/d' /etc/hosts
|
||||||
|
echo '${GITEA_HOSTS_IP} ${GITEA_HOSTS_NAME}' >> /etc/hosts
|
||||||
|
"
|
||||||
|
msg_ok "CT hosts updated"
|
||||||
|
}
|
||||||
|
|
||||||
|
resolve_script_dir() {
|
||||||
|
if [[ -n "${BASH_SOURCE[0]:-}" && -f "${BASH_SOURCE[0]}" ]]; then
|
||||||
|
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||||
|
else
|
||||||
|
SCRIPT_DIR=""
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
push_install_assets() {
|
||||||
|
local dest_dir="/tmp/myoffice-proxmox"
|
||||||
|
pct exec "$CTID" -- mkdir -p "$dest_dir"
|
||||||
|
|
||||||
|
if [[ -n "${SCRIPT_DIR}" && -f "${SCRIPT_DIR}/install-in-ct.sh" ]]; then
|
||||||
|
for f in install-in-ct.sh fetch-release.sh myoffice-api.service update.sh; do
|
||||||
|
pct push "$CTID" "${SCRIPT_DIR}/${f}" "${dest_dir}/${f}"
|
||||||
|
done
|
||||||
|
else
|
||||||
|
msg_info "Downloading install assets from REPO_RAW_BASE..."
|
||||||
|
for f in install-in-ct.sh fetch-release.sh myoffice-api.service update.sh; do
|
||||||
|
curl -fsSL "${REPO_RAW_BASE}/proxmox/${f}" -o "/tmp/${f}"
|
||||||
|
pct push "$CTID" "/tmp/${f}" "${dest_dir}/${f}"
|
||||||
|
done
|
||||||
|
msg_ok "Downloaded install assets"
|
||||||
|
fi
|
||||||
|
pct exec "$CTID" -- chmod +x "${dest_dir}/install-in-ct.sh" "${dest_dir}/fetch-release.sh" "${dest_dir}/update.sh"
|
||||||
|
}
|
||||||
|
|
||||||
|
# CT often cannot reach Gitea (isolated LAN). Download release on the Proxmox host, then push in.
|
||||||
|
download_release_on_host() {
|
||||||
|
local host_tar="/tmp/myoffice-publish.tar.gz"
|
||||||
|
local fetch_src
|
||||||
|
|
||||||
|
msg_info "Downloading release on Proxmox host..."
|
||||||
|
if [[ -n "${SCRIPT_DIR}" && -f "${SCRIPT_DIR}/fetch-release.sh" ]]; then
|
||||||
|
fetch_src="${SCRIPT_DIR}/fetch-release.sh"
|
||||||
|
else
|
||||||
|
curl -fsSL "${REPO_RAW_BASE}/proxmox/fetch-release.sh" -o /tmp/fetch-release.sh
|
||||||
|
fetch_src=/tmp/fetch-release.sh
|
||||||
|
fi
|
||||||
|
|
||||||
|
# shellcheck disable=SC1090
|
||||||
|
GITEA_URL="$GITEA_URL" \
|
||||||
|
GITEA_OWNER="$GITEA_OWNER" \
|
||||||
|
GITEA_REPO="$GITEA_REPO" \
|
||||||
|
RELEASE_TAG="$RELEASE_TAG" \
|
||||||
|
RELEASE_ASSET_URL="$RELEASE_ASSET_URL" \
|
||||||
|
GITEA_TOKEN="$GITEA_TOKEN" \
|
||||||
|
bash -c 'source "'"$fetch_src"'" && download_asset_to "'"$host_tar"'"'
|
||||||
|
|
||||||
|
pct push "$CTID" "$host_tar" "/tmp/myoffice-publish.tar.gz"
|
||||||
|
msg_ok "Release pushed into CT"
|
||||||
|
}
|
||||||
|
|
||||||
|
run_ct_install() {
|
||||||
|
if [[ -z "${PUBLIC_URL}" ]]; then
|
||||||
|
PUBLIC_URL="http://${CT_IP}:${API_PORT}"
|
||||||
|
fi
|
||||||
|
if [[ -z "${GITEA_URL}" || -z "${GITEA_OWNER}" ]] && [[ -z "${RELEASE_ASSET_URL}" ]]; then
|
||||||
|
msg_error "Set GITEA_URL + GITEA_OWNER (and GITEA_REPO), or RELEASE_ASSET_URL, before install."
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
download_release_on_host
|
||||||
|
msg_info "Installing PostgreSQL + ASP.NET runtime + Gitea release..."
|
||||||
|
pct exec "$CTID" -- env \
|
||||||
|
REPO_RAW_BASE="$REPO_RAW_BASE" \
|
||||||
|
APP_DIR="$APP_DIR" \
|
||||||
|
PUBLIC_URL="$PUBLIC_URL" \
|
||||||
|
API_PORT="$API_PORT" \
|
||||||
|
ASSETS_DIR="/tmp/myoffice-proxmox" \
|
||||||
|
GITEA_URL="$GITEA_URL" \
|
||||||
|
GITEA_OWNER="$GITEA_OWNER" \
|
||||||
|
GITEA_REPO="$GITEA_REPO" \
|
||||||
|
RELEASE_TAG="$RELEASE_TAG" \
|
||||||
|
RELEASE_ASSET_URL="$RELEASE_ASSET_URL" \
|
||||||
|
GITEA_TOKEN="$GITEA_TOKEN" \
|
||||||
|
LOCAL_RELEASE_TAR="/tmp/myoffice-publish.tar.gz" \
|
||||||
|
GITEA_HOSTS_IP="$GITEA_HOSTS_IP" \
|
||||||
|
GITEA_HOSTS_NAME="$GITEA_HOSTS_NAME" \
|
||||||
|
bash /tmp/myoffice-proxmox/install-in-ct.sh
|
||||||
|
msg_ok "Stack installed"
|
||||||
|
}
|
||||||
|
|
||||||
|
# --- main ---
|
||||||
|
header_info
|
||||||
|
echo -e "\n Loading..."
|
||||||
|
check_root
|
||||||
|
arch_check
|
||||||
|
pve_check
|
||||||
|
|
||||||
|
if ! whiptail --backtitle "MyOffice Proxmox" --title "MyOffice LXC" --yesno \
|
||||||
|
"This will create a new LXC with:\n\n • PostgreSQL\n • ASP.NET Core runtime\n • App from Gitea Release\n\nNo Docker, no nginx (point your proxy LXC at :9100).\n\nProceed?" 16 58; then
|
||||||
|
exit_script
|
||||||
|
fi
|
||||||
|
|
||||||
|
start_script
|
||||||
|
select_storage
|
||||||
|
ensure_template
|
||||||
|
resolve_script_dir
|
||||||
|
create_container
|
||||||
|
wait_for_network
|
||||||
|
ensure_ct_gitea_hosts
|
||||||
|
push_install_assets
|
||||||
|
run_ct_install
|
||||||
|
|
||||||
|
echo -e "\n${GN}${BOLD}Completed successfully!${CL}\n"
|
||||||
|
echo -e "${INFO}${YW}Stack in CT ${CTID}:${CL} PostgreSQL + ASP.NET + release ${RELEASE_TAG}"
|
||||||
|
echo -e "${GATEWAY}${BGN}Public URL ${PUBLIC_URL}${CL}"
|
||||||
|
echo -e "${GATEWAY}${BGN}Upstream http://${CT_IP}:${API_PORT}${CL} (for your nginx proxy)"
|
||||||
|
echo -e "${INFO}Demo login: user_UAH@user_UAH.userUAH / user_UAH"
|
||||||
|
echo -e "${INFO}Update: pct enter ${CTID} then ${APP_DIR}/proxmox/update.sh"
|
||||||
|
echo
|
||||||
@@ -0,0 +1,166 @@
|
|||||||
|
# Publish MyOffice API + SPA for Proxmox (artifacts only, no source).
|
||||||
|
# Usage (from repo root):
|
||||||
|
# .\proxmox\publish.ps1
|
||||||
|
# .\proxmox\publish.ps1 -OutDir D:\builds\myoffice
|
||||||
|
# .\proxmox\publish.ps1 -Tar # also write myoffice-publish.tar.gz next to OutDir
|
||||||
|
#
|
||||||
|
# Before build:
|
||||||
|
# 1) copy appsettings.shared.json → Development + Production overlays (only if missing)
|
||||||
|
# 2) restore NuGet + npm
|
||||||
|
#
|
||||||
|
# Output: <OutDir>/ with MyOffice.Web.dll + wwwroot/ (SPA, same-origin URLs)
|
||||||
|
|
||||||
|
[CmdletBinding()]
|
||||||
|
param(
|
||||||
|
[string]$OutDir = '',
|
||||||
|
[switch]$Tar
|
||||||
|
)
|
||||||
|
|
||||||
|
$ErrorActionPreference = 'Stop'
|
||||||
|
|
||||||
|
$scriptDir = Split-Path -Parent $MyInvocation.MyCommand.Path
|
||||||
|
$repoRoot = Resolve-Path (Join-Path $scriptDir '..')
|
||||||
|
Set-Location $repoRoot
|
||||||
|
|
||||||
|
if (-not $OutDir) {
|
||||||
|
$OutDir = Join-Path $repoRoot '_Published'
|
||||||
|
}
|
||||||
|
$OutDir = $ExecutionContext.SessionState.Path.GetUnresolvedProviderPathFromPSPath($OutDir)
|
||||||
|
|
||||||
|
$sharedDir = Join-Path $repoRoot 'MyOffice.Shared'
|
||||||
|
$sharedBase = Join-Path $sharedDir 'appsettings.shared.json'
|
||||||
|
if (-not (Test-Path $sharedBase)) {
|
||||||
|
throw "Missing $sharedBase"
|
||||||
|
}
|
||||||
|
|
||||||
|
Write-Host "-----------------------------------------------"
|
||||||
|
Write-Host "Copy appsettings.shared.json -> Development + Production (if missing)"
|
||||||
|
Write-Host "-----------------------------------------------"
|
||||||
|
foreach ($name in @('appsettings.shared.Development.json', 'appsettings.shared.Production.json')) {
|
||||||
|
$dest = Join-Path $sharedDir $name
|
||||||
|
if (Test-Path $dest) {
|
||||||
|
Write-Host "Skip (exists): $name"
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
Copy-Item $sharedBase $dest
|
||||||
|
Write-Host "Created: $name"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Write-Host "-----------------------------------------------"
|
||||||
|
Write-Host "Restore NuGet packages"
|
||||||
|
Write-Host "-----------------------------------------------"
|
||||||
|
dotnet restore 'MyOffice.Web\MyOffice.Web.csproj' --nologo
|
||||||
|
if ($LASTEXITCODE -ne 0) {
|
||||||
|
throw "dotnet restore failed with exit code $LASTEXITCODE"
|
||||||
|
}
|
||||||
|
|
||||||
|
$spaDir = Join-Path $repoRoot 'MyOffice.SPA'
|
||||||
|
$envSample = Join-Path $spaDir 'src\environments\environment.sample.ts'
|
||||||
|
$envLocal = Join-Path $spaDir 'src\environments\environment.ts'
|
||||||
|
if (-not (Test-Path $envLocal) -and (Test-Path $envSample)) {
|
||||||
|
Copy-Item $envSample $envLocal
|
||||||
|
}
|
||||||
|
|
||||||
|
Write-Host "-----------------------------------------------"
|
||||||
|
Write-Host "Restore npm packages (MyOffice.SPA)"
|
||||||
|
Write-Host "-----------------------------------------------"
|
||||||
|
Push-Location $spaDir
|
||||||
|
try {
|
||||||
|
if (Test-Path (Join-Path $spaDir 'package-lock.json')) {
|
||||||
|
npm ci
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
npm install
|
||||||
|
}
|
||||||
|
if ($LASTEXITCODE -ne 0) {
|
||||||
|
throw "npm restore failed with exit code $LASTEXITCODE"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
finally {
|
||||||
|
Pop-Location
|
||||||
|
}
|
||||||
|
|
||||||
|
Write-Host "-----------------------------------------------"
|
||||||
|
Write-Host "dotnet publish -> $OutDir"
|
||||||
|
Write-Host "-----------------------------------------------"
|
||||||
|
|
||||||
|
if (Test-Path $OutDir) {
|
||||||
|
Remove-Item -Recurse -Force $OutDir
|
||||||
|
}
|
||||||
|
New-Item -ItemType Directory -Path $OutDir | Out-Null
|
||||||
|
|
||||||
|
dotnet publish 'MyOffice.Web\MyOffice.Web.csproj' -c Release -o $OutDir --no-restore --nologo
|
||||||
|
if ($LASTEXITCODE -ne 0) {
|
||||||
|
throw "dotnet publish failed with exit code $LASTEXITCODE"
|
||||||
|
}
|
||||||
|
|
||||||
|
# CT owns Production overlays — do not ship local env-specific appsettings.
|
||||||
|
# Keep appsettings.json + appsettings.shared.json (required at runtime).
|
||||||
|
Get-ChildItem -Path $OutDir -Filter 'appsettings*.json' -File -ErrorAction SilentlyContinue |
|
||||||
|
Where-Object {
|
||||||
|
$_.Name -notin @('appsettings.json', 'appsettings.shared.json')
|
||||||
|
} |
|
||||||
|
Remove-Item -Force
|
||||||
|
|
||||||
|
Write-Host "-----------------------------------------------"
|
||||||
|
Write-Host "ng build (proxmox) -> $OutDir\wwwroot"
|
||||||
|
Write-Host "-----------------------------------------------"
|
||||||
|
|
||||||
|
Push-Location $spaDir
|
||||||
|
try {
|
||||||
|
$spaOut = Join-Path $env:TEMP ("myoffice-spa-" + [guid]::NewGuid().ToString('N'))
|
||||||
|
|
||||||
|
npx ng build --configuration proxmox --output-path $spaOut
|
||||||
|
if ($LASTEXITCODE -ne 0) {
|
||||||
|
throw "ng build failed with exit code $LASTEXITCODE"
|
||||||
|
}
|
||||||
|
|
||||||
|
$wwwroot = Join-Path $OutDir 'wwwroot'
|
||||||
|
if (Test-Path $wwwroot) {
|
||||||
|
Remove-Item -Recurse -Force $wwwroot
|
||||||
|
}
|
||||||
|
New-Item -ItemType Directory -Path $wwwroot | Out-Null
|
||||||
|
|
||||||
|
$browser = Join-Path $spaOut 'browser'
|
||||||
|
if (Test-Path $browser) {
|
||||||
|
Copy-Item -Path (Join-Path $browser '*') -Destination $wwwroot -Recurse -Force
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
Copy-Item -Path (Join-Path $spaOut '*') -Destination $wwwroot -Recurse -Force
|
||||||
|
}
|
||||||
|
Remove-Item -Recurse -Force $spaOut -ErrorAction SilentlyContinue
|
||||||
|
}
|
||||||
|
finally {
|
||||||
|
Pop-Location
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($Tar) {
|
||||||
|
$tarPath = Join-Path (Split-Path -Parent $OutDir) 'myoffice-publish.tar.gz'
|
||||||
|
if (Test-Path $tarPath) {
|
||||||
|
Remove-Item -Force $tarPath
|
||||||
|
}
|
||||||
|
Write-Host "-----------------------------------------------"
|
||||||
|
Write-Host "tar -> $tarPath"
|
||||||
|
Write-Host "-----------------------------------------------"
|
||||||
|
# Windows 10+ tar; paths relative to OutDir parent
|
||||||
|
Push-Location (Split-Path -Parent $OutDir)
|
||||||
|
try {
|
||||||
|
$leaf = Split-Path -Leaf $OutDir
|
||||||
|
tar -czf $tarPath $leaf
|
||||||
|
if ($LASTEXITCODE -ne 0) {
|
||||||
|
throw "tar failed with exit code $LASTEXITCODE"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
finally {
|
||||||
|
Pop-Location
|
||||||
|
}
|
||||||
|
Write-Host "Archive: $tarPath"
|
||||||
|
}
|
||||||
|
|
||||||
|
Write-Host ""
|
||||||
|
Write-Host "Published to $OutDir"
|
||||||
|
Write-Host "Next (Gitea): .\proxmox\publish.ps1 -Tar ; .\proxmox\release.ps1 -GiteaUrl ... -Owner ... -Tag v1.2.3"
|
||||||
|
Write-Host "Linux/CI: ./proxmox/publish.sh --tar ; ./proxmox/release.sh ..."
|
||||||
|
Write-Host "Lab SSH: .\proxmox\deploy.ps1 -TargetHost <ct-ip>"
|
||||||
|
Write-Host ""
|
||||||
Executable
+141
@@ -0,0 +1,141 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# Publish MyOffice API + SPA for Proxmox (artifacts only, no source).
|
||||||
|
# Usage (from repo root):
|
||||||
|
# ./proxmox/publish.sh
|
||||||
|
# ./proxmox/publish.sh --out-dir /tmp/myoffice-out
|
||||||
|
# ./proxmox/publish.sh --tar
|
||||||
|
#
|
||||||
|
# Output: <out-dir>/ with MyOffice.Web.dll + wwwroot/ (SPA, same-origin URLs)
|
||||||
|
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
script_dir="$(cd "$(dirname "$0")" && pwd)"
|
||||||
|
repo_root="$(cd "$script_dir/.." && pwd)"
|
||||||
|
cd "$repo_root"
|
||||||
|
|
||||||
|
out_dir="${OUT_DIR:-_Published}"
|
||||||
|
do_tar=false
|
||||||
|
|
||||||
|
while [[ $# -gt 0 ]]; do
|
||||||
|
case "$1" in
|
||||||
|
--tar | -Tar)
|
||||||
|
do_tar=true
|
||||||
|
shift
|
||||||
|
;;
|
||||||
|
--out-dir)
|
||||||
|
out_dir="$2"
|
||||||
|
shift 2
|
||||||
|
;;
|
||||||
|
-h | --help)
|
||||||
|
echo "Usage: $0 [--tar] [--out-dir DIR]"
|
||||||
|
exit 0
|
||||||
|
;;
|
||||||
|
*)
|
||||||
|
echo "Unknown argument: $1" >&2
|
||||||
|
exit 1
|
||||||
|
;;
|
||||||
|
esac
|
||||||
|
done
|
||||||
|
|
||||||
|
# Resolve relative out_dir against repo root
|
||||||
|
if [[ "$out_dir" != /* ]]; then
|
||||||
|
out_dir="$repo_root/$out_dir"
|
||||||
|
fi
|
||||||
|
|
||||||
|
shared_dir="$repo_root/MyOffice.Shared"
|
||||||
|
shared_base="$shared_dir/appsettings.shared.json"
|
||||||
|
if [[ ! -f "$shared_base" ]]; then
|
||||||
|
echo "Missing $shared_base" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "-----------------------------------------------"
|
||||||
|
echo "Copy appsettings.shared.json -> Development + Production (if missing)"
|
||||||
|
echo "-----------------------------------------------"
|
||||||
|
for name in appsettings.shared.Development.json appsettings.shared.Production.json; do
|
||||||
|
dest="$shared_dir/$name"
|
||||||
|
if [[ -f "$dest" ]]; then
|
||||||
|
echo "Skip (exists): $name"
|
||||||
|
else
|
||||||
|
cp "$shared_base" "$dest"
|
||||||
|
echo "Created: $name"
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
|
||||||
|
echo "-----------------------------------------------"
|
||||||
|
echo "Restore NuGet packages"
|
||||||
|
echo "-----------------------------------------------"
|
||||||
|
dotnet restore MyOffice.Web/MyOffice.Web.csproj --nologo
|
||||||
|
|
||||||
|
spa_dir="$repo_root/MyOffice.SPA"
|
||||||
|
env_sample="$spa_dir/src/environments/environment.sample.ts"
|
||||||
|
env_local="$spa_dir/src/environments/environment.ts"
|
||||||
|
if [[ ! -f "$env_local" && -f "$env_sample" ]]; then
|
||||||
|
cp "$env_sample" "$env_local"
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "-----------------------------------------------"
|
||||||
|
echo "Restore npm packages (MyOffice.SPA)"
|
||||||
|
echo "-----------------------------------------------"
|
||||||
|
pushd "$spa_dir" >/dev/null
|
||||||
|
if [[ -f package-lock.json ]]; then
|
||||||
|
npm ci
|
||||||
|
else
|
||||||
|
npm install
|
||||||
|
fi
|
||||||
|
popd >/dev/null
|
||||||
|
|
||||||
|
echo "-----------------------------------------------"
|
||||||
|
echo "dotnet publish -> $out_dir"
|
||||||
|
echo "-----------------------------------------------"
|
||||||
|
rm -rf "$out_dir"
|
||||||
|
mkdir -p "$out_dir"
|
||||||
|
|
||||||
|
dotnet publish MyOffice.Web/MyOffice.Web.csproj -c Release -o "$out_dir" --no-restore --nologo
|
||||||
|
|
||||||
|
# CT owns Production overlays — do not ship local env-specific appsettings.
|
||||||
|
while IFS= read -r -d '' f; do
|
||||||
|
base="$(basename "$f")"
|
||||||
|
if [[ "$base" != appsettings.json && "$base" != appsettings.shared.json ]]; then
|
||||||
|
rm -f "$f"
|
||||||
|
fi
|
||||||
|
done < <(find "$out_dir" -maxdepth 1 -name 'appsettings*.json' -print0)
|
||||||
|
|
||||||
|
echo "-----------------------------------------------"
|
||||||
|
echo "ng build (proxmox) -> $out_dir/wwwroot"
|
||||||
|
echo "-----------------------------------------------"
|
||||||
|
spa_out="$(mktemp -d /tmp/myoffice-spa.XXXXXX)"
|
||||||
|
pushd "$spa_dir" >/dev/null
|
||||||
|
npx ng build --configuration proxmox --output-path "$spa_out"
|
||||||
|
popd >/dev/null
|
||||||
|
|
||||||
|
wwwroot="$out_dir/wwwroot"
|
||||||
|
rm -rf "$wwwroot"
|
||||||
|
mkdir -p "$wwwroot"
|
||||||
|
|
||||||
|
if [[ -d "$spa_out/browser" ]]; then
|
||||||
|
cp -a "$spa_out/browser/." "$wwwroot/"
|
||||||
|
else
|
||||||
|
cp -a "$spa_out/." "$wwwroot/"
|
||||||
|
fi
|
||||||
|
rm -rf "$spa_out"
|
||||||
|
|
||||||
|
if [[ "$do_tar" == true ]]; then
|
||||||
|
out_parent="$(cd "$(dirname "$out_dir")" && pwd)"
|
||||||
|
out_leaf="$(basename "$out_dir")"
|
||||||
|
tar_path="$out_parent/myoffice-publish.tar.gz"
|
||||||
|
rm -f "$tar_path"
|
||||||
|
echo "-----------------------------------------------"
|
||||||
|
echo "tar -> $tar_path"
|
||||||
|
echo "-----------------------------------------------"
|
||||||
|
pushd "$out_parent" >/dev/null
|
||||||
|
tar -czf "$tar_path" "$out_leaf"
|
||||||
|
popd >/dev/null
|
||||||
|
echo "Archive: $tar_path"
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo ""
|
||||||
|
echo "Published to $out_dir"
|
||||||
|
echo "Next (Gitea): ./proxmox/publish.sh --tar ; ./proxmox/release.sh ..."
|
||||||
|
echo "Lab SSH: ./proxmox/deploy.ps1 -TargetHost <ct-ip>"
|
||||||
|
echo ""
|
||||||
@@ -0,0 +1,154 @@
|
|||||||
|
# Upload myoffice-publish.tar.gz to a Gitea Release on the public repo.
|
||||||
|
# Usage:
|
||||||
|
# $env:GITEA_TOKEN = '<token>'
|
||||||
|
# .\proxmox\publish.ps1 -Tar
|
||||||
|
# .\proxmox\release.ps1 -GiteaUrl https://gitea.example.com -Owner org -Repo myoffice-public -Tag v1.2.3
|
||||||
|
#
|
||||||
|
# Token needs write:repository on the public repo.
|
||||||
|
|
||||||
|
[CmdletBinding()]
|
||||||
|
param(
|
||||||
|
[Parameter(Mandatory = $true)]
|
||||||
|
[string]$GiteaUrl,
|
||||||
|
|
||||||
|
[Parameter(Mandatory = $true)]
|
||||||
|
[string]$Owner,
|
||||||
|
|
||||||
|
[string]$Repo = 'myoffice-public',
|
||||||
|
|
||||||
|
[Parameter(Mandatory = $true)]
|
||||||
|
[string]$Tag,
|
||||||
|
|
||||||
|
[string]$Token = $env:GITEA_TOKEN,
|
||||||
|
|
||||||
|
[string]$AssetPath = '',
|
||||||
|
|
||||||
|
[string]$Title = '',
|
||||||
|
|
||||||
|
[string]$Body = 'MyOffice published API + SPA (Proxmox CT artifact).',
|
||||||
|
|
||||||
|
[string]$Target = 'main'
|
||||||
|
)
|
||||||
|
|
||||||
|
$ErrorActionPreference = 'Stop'
|
||||||
|
|
||||||
|
if (-not $Token) {
|
||||||
|
throw 'GITEA_TOKEN (or -Token) is required.'
|
||||||
|
}
|
||||||
|
|
||||||
|
$scriptDir = Split-Path -Parent $MyInvocation.MyCommand.Path
|
||||||
|
$repoRoot = Resolve-Path (Join-Path $scriptDir '..')
|
||||||
|
|
||||||
|
if (-not $AssetPath) {
|
||||||
|
$AssetPath = Join-Path $repoRoot 'myoffice-publish.tar.gz'
|
||||||
|
}
|
||||||
|
if (-not (Test-Path $AssetPath)) {
|
||||||
|
throw "Asset not found: $AssetPath — run .\proxmox\publish.ps1 -Tar first."
|
||||||
|
}
|
||||||
|
|
||||||
|
$GiteaUrl = $GiteaUrl.TrimEnd('/')
|
||||||
|
$api = "$GiteaUrl/api/v1"
|
||||||
|
$assetName = 'myoffice-publish.tar.gz'
|
||||||
|
if (-not $Title) { $Title = $Tag }
|
||||||
|
|
||||||
|
$headers = @{
|
||||||
|
Authorization = "token $Token"
|
||||||
|
Accept = 'application/json'
|
||||||
|
}
|
||||||
|
|
||||||
|
function Invoke-GiteaJson {
|
||||||
|
param(
|
||||||
|
[string]$Method,
|
||||||
|
[string]$Uri,
|
||||||
|
[object]$BodyObject = $null
|
||||||
|
)
|
||||||
|
$params = @{
|
||||||
|
Method = $Method
|
||||||
|
Uri = $Uri
|
||||||
|
Headers = $headers
|
||||||
|
}
|
||||||
|
if ($null -ne $BodyObject) {
|
||||||
|
$params.ContentType = 'application/json'
|
||||||
|
$params.Body = ($BodyObject | ConvertTo-Json -Depth 6)
|
||||||
|
}
|
||||||
|
return Invoke-RestMethod @params
|
||||||
|
}
|
||||||
|
|
||||||
|
Write-Host "-----------------------------------------------"
|
||||||
|
Write-Host "Gitea release $Owner/$Repo tag $Tag"
|
||||||
|
Write-Host "-----------------------------------------------"
|
||||||
|
|
||||||
|
$release = $null
|
||||||
|
try {
|
||||||
|
$release = Invoke-GiteaJson -Method GET -Uri "$api/repos/$Owner/$Repo/releases/tags/$Tag"
|
||||||
|
Write-Host "Release $Tag already exists (id $($release.id))."
|
||||||
|
}
|
||||||
|
catch {
|
||||||
|
Write-Host "Creating release $Tag..."
|
||||||
|
$release = Invoke-GiteaJson -Method POST -Uri "$api/repos/$Owner/$Repo/releases" -BodyObject @{
|
||||||
|
tag_name = $Tag
|
||||||
|
target_commitish = $Target
|
||||||
|
name = $Title
|
||||||
|
body = $Body
|
||||||
|
draft = $false
|
||||||
|
prerelease = $false
|
||||||
|
}
|
||||||
|
Write-Host "Created release id $($release.id)."
|
||||||
|
}
|
||||||
|
|
||||||
|
# Remove existing asset with the same name so re-upload works
|
||||||
|
$existing = @($release.assets) | Where-Object { $_.name -eq $assetName }
|
||||||
|
foreach ($a in $existing) {
|
||||||
|
Write-Host "Deleting existing asset id $($a.id) ($($a.name))..."
|
||||||
|
Invoke-GiteaJson -Method DELETE -Uri "$api/repos/$Owner/$Repo/releases/$($release.id)/assets/$($a.id)" | Out-Null
|
||||||
|
}
|
||||||
|
|
||||||
|
$uploadUrl = "$api/repos/$Owner/$Repo/releases/$($release.id)/assets?name=$assetName"
|
||||||
|
Write-Host "Uploading $AssetPath ..."
|
||||||
|
|
||||||
|
# Prefer curl multipart (reliable on Windows PowerShell 5 + 7)
|
||||||
|
$curl = Get-Command curl.exe -ErrorAction SilentlyContinue
|
||||||
|
if ($curl) {
|
||||||
|
$args = @(
|
||||||
|
'-fsS', '-X', 'POST',
|
||||||
|
'-H', "Authorization: token $Token",
|
||||||
|
'-F', "attachment=@$AssetPath",
|
||||||
|
$uploadUrl
|
||||||
|
)
|
||||||
|
& curl.exe @args
|
||||||
|
if ($LASTEXITCODE -ne 0) {
|
||||||
|
throw "curl upload failed with exit code $LASTEXITCODE"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
# Fallback: .NET multipart
|
||||||
|
Add-Type -AssemblyName System.Net.Http
|
||||||
|
$handler = [System.Net.Http.HttpClientHandler]::new()
|
||||||
|
$client = [System.Net.Http.HttpClient]::new($handler)
|
||||||
|
$client.DefaultRequestHeaders.Add('Authorization', "token $Token")
|
||||||
|
$content = [System.Net.Http.MultipartFormDataContent]::new()
|
||||||
|
$fs = [System.IO.File]::OpenRead($AssetPath)
|
||||||
|
try {
|
||||||
|
$streamContent = [System.Net.Http.StreamContent]::new($fs)
|
||||||
|
$streamContent.Headers.ContentType = [System.Net.Http.Headers.MediaTypeHeaderValue]::Parse('application/octet-stream')
|
||||||
|
$content.Add($streamContent, 'attachment', $assetName)
|
||||||
|
$resp = $client.PostAsync($uploadUrl, $content).Result
|
||||||
|
if (-not $resp.IsSuccessStatusCode) {
|
||||||
|
$err = $resp.Content.ReadAsStringAsync().Result
|
||||||
|
throw "Upload failed: $($resp.StatusCode) $err"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
finally {
|
||||||
|
$fs.Dispose()
|
||||||
|
$content.Dispose()
|
||||||
|
$client.Dispose()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$downloadUrl = "$GiteaUrl/$Owner/$Repo/releases/download/$Tag/$assetName"
|
||||||
|
Write-Host ""
|
||||||
|
Write-Host "Uploaded. Download URL:"
|
||||||
|
Write-Host " $downloadUrl"
|
||||||
|
Write-Host "Latest API:"
|
||||||
|
Write-Host " $api/repos/$Owner/$Repo/releases/latest"
|
||||||
|
Write-Host ""
|
||||||
Executable
+158
@@ -0,0 +1,158 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# Upload myoffice-publish.tar.gz to a Gitea Release on the public repo.
|
||||||
|
# Usage:
|
||||||
|
# export GITEA_TOKEN='<token>'
|
||||||
|
# ./proxmox/publish.sh --tar
|
||||||
|
# ./proxmox/release.sh --gitea-url https://gitea.example.com --owner org --repo myoffice-public --tag v1.2.3
|
||||||
|
#
|
||||||
|
# Token needs write:repository on the public repo.
|
||||||
|
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
script_dir="$(cd "$(dirname "$0")" && pwd)"
|
||||||
|
repo_root="$(cd "$script_dir/.." && pwd)"
|
||||||
|
|
||||||
|
gitea_url="${GITEA_URL:-}"
|
||||||
|
owner="${GITEA_OWNER:-}"
|
||||||
|
repo="${GITEA_REPO:-myoffice-public}"
|
||||||
|
tag="${TAG:-}"
|
||||||
|
token="${GITEA_TOKEN:-}"
|
||||||
|
asset_path="${ASSET_PATH:-}"
|
||||||
|
title="${TITLE:-}"
|
||||||
|
body="${BODY:-MyOffice published API + SPA (Proxmox CT artifact).}"
|
||||||
|
target="${TARGET:-main}"
|
||||||
|
|
||||||
|
usage() {
|
||||||
|
echo "Usage: $0 --gitea-url URL --owner OWNER --tag TAG [--repo REPO] [--asset-path PATH]" >&2
|
||||||
|
exit 1
|
||||||
|
}
|
||||||
|
|
||||||
|
while [[ $# -gt 0 ]]; do
|
||||||
|
case "$1" in
|
||||||
|
--gitea-url)
|
||||||
|
gitea_url="$2"
|
||||||
|
shift 2
|
||||||
|
;;
|
||||||
|
--owner)
|
||||||
|
owner="$2"
|
||||||
|
shift 2
|
||||||
|
;;
|
||||||
|
--repo)
|
||||||
|
repo="$2"
|
||||||
|
shift 2
|
||||||
|
;;
|
||||||
|
--tag)
|
||||||
|
tag="$2"
|
||||||
|
shift 2
|
||||||
|
;;
|
||||||
|
--asset-path)
|
||||||
|
asset_path="$2"
|
||||||
|
shift 2
|
||||||
|
;;
|
||||||
|
--title)
|
||||||
|
title="$2"
|
||||||
|
shift 2
|
||||||
|
;;
|
||||||
|
--body)
|
||||||
|
body="$2"
|
||||||
|
shift 2
|
||||||
|
;;
|
||||||
|
--target)
|
||||||
|
target="$2"
|
||||||
|
shift 2
|
||||||
|
;;
|
||||||
|
-h | --help)
|
||||||
|
usage
|
||||||
|
;;
|
||||||
|
*)
|
||||||
|
echo "Unknown argument: $1" >&2
|
||||||
|
usage
|
||||||
|
;;
|
||||||
|
esac
|
||||||
|
done
|
||||||
|
|
||||||
|
if [[ -z "$token" ]]; then
|
||||||
|
echo "GITEA_TOKEN (or export before run) is required." >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
if [[ -z "$gitea_url" || -z "$owner" || -z "$tag" ]]; then
|
||||||
|
usage
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [[ -z "$asset_path" ]]; then
|
||||||
|
asset_path="$repo_root/myoffice-publish.tar.gz"
|
||||||
|
fi
|
||||||
|
if [[ ! -f "$asset_path" ]]; then
|
||||||
|
echo "Asset not found: $asset_path — run ./proxmox/publish.sh --tar first." >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
gitea_url="${gitea_url%/}"
|
||||||
|
api="$gitea_url/api/v1"
|
||||||
|
asset_name="myoffice-publish.tar.gz"
|
||||||
|
if [[ -z "$title" ]]; then
|
||||||
|
title="$tag"
|
||||||
|
fi
|
||||||
|
|
||||||
|
auth_header="Authorization: token $token"
|
||||||
|
|
||||||
|
api_call() {
|
||||||
|
local method="$1"
|
||||||
|
local uri="$2"
|
||||||
|
local payload="${3:-}"
|
||||||
|
if [[ -n "$payload" ]]; then
|
||||||
|
curl -fsS -X "$method" -H "$auth_header" -H "Accept: application/json" \
|
||||||
|
-H "Content-Type: application/json" -d "$payload" "$uri"
|
||||||
|
else
|
||||||
|
curl -fsS -X "$method" -H "$auth_header" -H "Accept: application/json" "$uri"
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
release_json=""
|
||||||
|
if release_json="$(api_call GET "$api/repos/$owner/$repo/releases/tags/$tag" 2>/dev/null)"; then
|
||||||
|
release_id="$(python3 -c "import json,sys; print(json.load(sys.stdin)['id'])" <<<"$release_json")"
|
||||||
|
echo "Release $tag already exists (id $release_id)."
|
||||||
|
else
|
||||||
|
echo "Creating release $tag..."
|
||||||
|
payload="$(TAG="$tag" TITLE="$title" BODY="$body" TARGET="$target" python3 <<'PY'
|
||||||
|
import json, os
|
||||||
|
print(json.dumps({
|
||||||
|
"tag_name": os.environ["TAG"],
|
||||||
|
"target_commitish": os.environ["TARGET"],
|
||||||
|
"name": os.environ["TITLE"],
|
||||||
|
"body": os.environ["BODY"],
|
||||||
|
"draft": False,
|
||||||
|
"prerelease": False,
|
||||||
|
}))
|
||||||
|
PY
|
||||||
|
)"
|
||||||
|
release_json="$(api_call POST "$api/repos/$owner/$repo/releases" "$payload")"
|
||||||
|
release_id="$(python3 -c "import json,sys; print(json.load(sys.stdin)['id'])" <<<"$release_json")"
|
||||||
|
echo "Created release id $release_id."
|
||||||
|
fi
|
||||||
|
|
||||||
|
asset_ids="$(python3 -c "
|
||||||
|
import json, sys
|
||||||
|
data = json.load(sys.stdin)
|
||||||
|
for a in data.get('assets', []):
|
||||||
|
if a.get('name') == '$asset_name':
|
||||||
|
print(a['id'])
|
||||||
|
" <<<"$release_json")"
|
||||||
|
|
||||||
|
while IFS= read -r asset_id; do
|
||||||
|
[[ -z "$asset_id" ]] && continue
|
||||||
|
echo "Deleting existing asset id $asset_id ($asset_name)..."
|
||||||
|
api_call DELETE "$api/repos/$owner/$repo/releases/$release_id/assets/$asset_id" >/dev/null || true
|
||||||
|
done <<<"$asset_ids"
|
||||||
|
|
||||||
|
upload_url="$api/repos/$owner/$repo/releases/$release_id/assets?name=$asset_name"
|
||||||
|
echo "Uploading $asset_path ..."
|
||||||
|
curl -fsS -X POST -H "$auth_header" -F "attachment=@$asset_path" "$upload_url"
|
||||||
|
|
||||||
|
download_url="$gitea_url/$owner/$repo/releases/download/$tag/$asset_name"
|
||||||
|
echo ""
|
||||||
|
echo "Uploaded. Download URL:"
|
||||||
|
echo " $download_url"
|
||||||
|
echo "Latest API:"
|
||||||
|
echo " $api/repos/$owner/$repo/releases/latest"
|
||||||
|
echo ""
|
||||||
@@ -0,0 +1,221 @@
|
|||||||
|
# Sync buildable source + Proxmox scripts to the public Gitea repo.
|
||||||
|
# Allowlist: .publishinclude | Skips: Docker, private .gitignore, *.bat, tests, docs, CI, junk
|
||||||
|
#
|
||||||
|
# Usage:
|
||||||
|
# $env:GITEA_TOKEN = '<token>'
|
||||||
|
# .\proxmox\sync-public.ps1 -GiteaUrl https://gitea.example.com -Owner org -Repo myoffice_public -Push
|
||||||
|
|
||||||
|
[CmdletBinding()]
|
||||||
|
param(
|
||||||
|
[Parameter(Mandatory = $true)]
|
||||||
|
[string]$GiteaUrl,
|
||||||
|
|
||||||
|
[Parameter(Mandatory = $true)]
|
||||||
|
[string]$Owner,
|
||||||
|
|
||||||
|
[string]$Repo = 'myoffice-public',
|
||||||
|
|
||||||
|
[string]$Branch = 'master',
|
||||||
|
|
||||||
|
[string]$Token = $env:GITEA_TOKEN,
|
||||||
|
|
||||||
|
[string]$WorkDir = '',
|
||||||
|
|
||||||
|
[switch]$Push
|
||||||
|
)
|
||||||
|
|
||||||
|
$ErrorActionPreference = 'Stop'
|
||||||
|
|
||||||
|
$scriptDir = Split-Path -Parent $MyInvocation.MyCommand.Path
|
||||||
|
$repoRoot = Resolve-Path (Join-Path $scriptDir '..')
|
||||||
|
Set-Location $repoRoot
|
||||||
|
|
||||||
|
$GiteaUrl = $GiteaUrl.TrimEnd('/')
|
||||||
|
$includeFile = Join-Path $repoRoot '.publishinclude'
|
||||||
|
if (-not (Test-Path $includeFile)) {
|
||||||
|
throw "Missing $includeFile"
|
||||||
|
}
|
||||||
|
|
||||||
|
if (-not $WorkDir) {
|
||||||
|
$WorkDir = Join-Path $env:TEMP 'myoffice-public-sync'
|
||||||
|
}
|
||||||
|
|
||||||
|
function Test-SkipRel([string]$RelPath) {
|
||||||
|
$n = $RelPath -replace '\\', '/'
|
||||||
|
$base = Split-Path -Leaf $n
|
||||||
|
|
||||||
|
if ($n -eq 'Docker' -or $n.StartsWith('Docker/')) { return $true }
|
||||||
|
if ($base -eq '.gitignore') { return $true }
|
||||||
|
if ($base -like '*.bat') { return $true }
|
||||||
|
|
||||||
|
if ($n -eq 'MyOffice.Tests' -or $n.StartsWith('MyOffice.Tests/')) { return $true }
|
||||||
|
if ($n -eq 'docs' -or $n.StartsWith('docs/')) { return $true }
|
||||||
|
if ($n -eq '.gitea' -or $n.StartsWith('.gitea/')) { return $true }
|
||||||
|
if ($base -eq '.dockerignore' -or $base -eq 'gulpfile.js' -or $base -eq 'build.ps1' -or $base -eq 'linux_deploy.sh') { return $true }
|
||||||
|
if ($n -eq 'proxmox/README.public.md') { return $true }
|
||||||
|
|
||||||
|
if ($n -match '(^|/)node_modules(/|$)' -or $n -match '(^|/)(bin|obj|\.angular)(/|$)') { return $true }
|
||||||
|
if ($n -eq '_Published' -or $n.StartsWith('_Published/') -or $base -eq 'myoffice-publish.tar.gz') { return $true }
|
||||||
|
if ($base -eq '.env' -or $base -eq 'env.local' -or $base -like '.env.*') { return $true }
|
||||||
|
if ($base -eq 'appsettings.Docker.json' -or $base -eq 'appsettings.shared.Docker.json' -or $base -eq 'environment.docker.ts') { return $true }
|
||||||
|
if ($base -like '*.user' -or $base -like '*.suo') { return $true }
|
||||||
|
if ($base -eq 'karma.conf.js' -or $base -like '*.spec.ts') { return $true }
|
||||||
|
|
||||||
|
return $false
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($Token) {
|
||||||
|
$hostPart = $GiteaUrl -replace '^https://', '' -replace '^http://', ''
|
||||||
|
$scheme = if ($GiteaUrl -match '^http://') { 'http' } else { 'https' }
|
||||||
|
$remote = "${scheme}://oauth2:${Token}@${hostPart}/${Owner}/${Repo}.git"
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
$remote = "${GiteaUrl}/${Owner}/${Repo}.git"
|
||||||
|
Write-Host "No token — using remote as-is (SSH URL recommended): $remote"
|
||||||
|
}
|
||||||
|
|
||||||
|
Write-Host "-----------------------------------------------"
|
||||||
|
Write-Host "Sync allowlist (.publishinclude) -> $Owner/$Repo ($Branch)"
|
||||||
|
Write-Host "WorkDir: $WorkDir"
|
||||||
|
Write-Host "-----------------------------------------------"
|
||||||
|
|
||||||
|
if (Test-Path $WorkDir) {
|
||||||
|
Remove-Item -Recurse -Force $WorkDir
|
||||||
|
}
|
||||||
|
New-Item -ItemType Directory -Path $WorkDir | Out-Null
|
||||||
|
|
||||||
|
$cloned = $false
|
||||||
|
git clone --depth 1 --branch $Branch $remote $WorkDir 2>$null
|
||||||
|
if ($LASTEXITCODE -eq 0) { $cloned = $true }
|
||||||
|
|
||||||
|
if (-not $cloned) {
|
||||||
|
Write-Host "Clone failed or empty repo — initializing new git repo..."
|
||||||
|
git -C $WorkDir init -b $Branch
|
||||||
|
if ($LASTEXITCODE -ne 0) {
|
||||||
|
git -C $WorkDir init
|
||||||
|
git -C $WorkDir checkout -b $Branch 2>$null
|
||||||
|
}
|
||||||
|
git -C $WorkDir remote remove origin 2>$null
|
||||||
|
git -C $WorkDir remote add origin $remote
|
||||||
|
}
|
||||||
|
|
||||||
|
Get-ChildItem -Path $WorkDir -Force | Where-Object { $_.Name -ne '.git' } | ForEach-Object {
|
||||||
|
Remove-Item $_.FullName -Recurse -Force
|
||||||
|
}
|
||||||
|
|
||||||
|
$tracked = @(git -C $repoRoot ls-files)
|
||||||
|
$trackedSet = [System.Collections.Generic.HashSet[string]]::new([StringComparer]::OrdinalIgnoreCase)
|
||||||
|
foreach ($t in $tracked) { [void]$trackedSet.Add(($t -replace '\\', '/')) }
|
||||||
|
|
||||||
|
$copied = 0
|
||||||
|
$skipped = 0
|
||||||
|
|
||||||
|
function Copy-Rel([string]$Rel) {
|
||||||
|
$script:n = $Rel -replace '\\', '/'
|
||||||
|
if (Test-SkipRel $n) {
|
||||||
|
$script:skipped++
|
||||||
|
return
|
||||||
|
}
|
||||||
|
$src = Join-Path $repoRoot $n
|
||||||
|
if (-not (Test-Path -LiteralPath $src)) { return }
|
||||||
|
$dest = Join-Path $WorkDir $n
|
||||||
|
$destDir = Split-Path -Parent $dest
|
||||||
|
if (-not (Test-Path $destDir)) {
|
||||||
|
New-Item -ItemType Directory -Path $destDir -Force | Out-Null
|
||||||
|
}
|
||||||
|
Copy-Item -LiteralPath $src -Destination $dest -Force
|
||||||
|
$script:copied++
|
||||||
|
}
|
||||||
|
|
||||||
|
Get-Content -LiteralPath $includeFile | ForEach-Object {
|
||||||
|
$line = $_
|
||||||
|
if ($line -match '#') { $line = $line.Substring(0, $line.IndexOf('#')) }
|
||||||
|
$line = $line.Trim()
|
||||||
|
if (-not $line) { return }
|
||||||
|
|
||||||
|
$norm = $line -replace '\\', '/'
|
||||||
|
if ($norm.EndsWith('/')) {
|
||||||
|
$prefix = $norm
|
||||||
|
$dir = $norm.TrimEnd('/')
|
||||||
|
if (-not (Test-Path (Join-Path $repoRoot $dir))) {
|
||||||
|
Write-Host "Skip missing dir: $dir"
|
||||||
|
return
|
||||||
|
}
|
||||||
|
foreach ($t in $tracked) {
|
||||||
|
$tn = $t -replace '\\', '/'
|
||||||
|
if ($tn.StartsWith($prefix)) { Copy-Rel $tn }
|
||||||
|
}
|
||||||
|
Write-Host "Synced $dir/"
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
if (Test-SkipRel $norm) {
|
||||||
|
$skipped++
|
||||||
|
Write-Host "Skip excluded: $norm"
|
||||||
|
return
|
||||||
|
}
|
||||||
|
$src = Join-Path $repoRoot $norm
|
||||||
|
if (-not (Test-Path -LiteralPath $src)) {
|
||||||
|
Write-Host "Skip missing file: $norm"
|
||||||
|
return
|
||||||
|
}
|
||||||
|
Copy-Rel $norm
|
||||||
|
Write-Host "Synced $norm"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Write-Host "Copied $copied files (skipped $skipped)."
|
||||||
|
|
||||||
|
$repoRawBase = "$GiteaUrl/$Owner/$Repo/raw/branch/$Branch"
|
||||||
|
$template = Join-Path $scriptDir 'README.public.md'
|
||||||
|
if (-not (Test-Path $template)) { throw "Missing $template" }
|
||||||
|
$readme = Get-Content -LiteralPath $template -Raw -Encoding utf8
|
||||||
|
$readme = $readme.
|
||||||
|
Replace('__GITEA_URL__', $GiteaUrl).
|
||||||
|
Replace('__GITEA_OWNER__', $Owner).
|
||||||
|
Replace('__GITEA_REPO__', $Repo).
|
||||||
|
Replace('__GITEA_BRANCH__', $Branch).
|
||||||
|
Replace('__REPO_RAW_BASE__', $repoRawBase)
|
||||||
|
Set-Content -LiteralPath (Join-Path $WorkDir 'README.md') -Value $readme -Encoding utf8 -NoNewline
|
||||||
|
Write-Host "Wrote public README.md"
|
||||||
|
|
||||||
|
@(
|
||||||
|
'**/bin/',
|
||||||
|
'**/obj/',
|
||||||
|
'**/node_modules/',
|
||||||
|
'**/.angular/',
|
||||||
|
'_Published/',
|
||||||
|
'myoffice-publish.tar.gz',
|
||||||
|
'*.user',
|
||||||
|
'.env',
|
||||||
|
'env.local',
|
||||||
|
'MyOffice.SPA/src/environments/environment.ts',
|
||||||
|
'MyOffice.Shared/appsettings.shared.Development.json',
|
||||||
|
'MyOffice.Shared/appsettings.shared.Production.json'
|
||||||
|
) | Set-Content -LiteralPath (Join-Path $WorkDir '.gitignore') -Encoding utf8
|
||||||
|
|
||||||
|
git -C $WorkDir add -A
|
||||||
|
$status = git -C $WorkDir status --porcelain
|
||||||
|
if (-not $status) {
|
||||||
|
Write-Host "No changes to commit."
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
git -C $WorkDir -c user.email='myoffice-sync@local' -c user.name='myoffice-sync' `
|
||||||
|
commit -m "sync build tree from private myoffice"
|
||||||
|
Write-Host "Committed public snapshot."
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($Push) {
|
||||||
|
Write-Host "Pushing to origin $Branch..."
|
||||||
|
git -C $WorkDir push -u origin "HEAD:$Branch"
|
||||||
|
if ($LASTEXITCODE -ne 0) { throw "git push failed with exit code $LASTEXITCODE" }
|
||||||
|
Write-Host "Pushed."
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
Write-Host "Dry run (no push). Re-run with -Push to publish."
|
||||||
|
Write-Host "Work tree left at: $WorkDir"
|
||||||
|
}
|
||||||
|
|
||||||
|
Write-Host ""
|
||||||
|
Write-Host "Proxmox one-liner:"
|
||||||
|
Write-Host (' bash -c "$(curl -fsSL {0}/proxmox/myoffice.sh)"' -f $repoRawBase)
|
||||||
|
Write-Host ""
|
||||||
Executable
+228
@@ -0,0 +1,228 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# Sync buildable source + Proxmox scripts to the public Gitea repo.
|
||||||
|
# Allowlist: .publishinclude | Skips: Docker, .gitignore, *.bat, tests, docs, CI, junk
|
||||||
|
#
|
||||||
|
# Usage:
|
||||||
|
# export GITEA_TOKEN='<token>'
|
||||||
|
# ./proxmox/sync-public.sh --gitea-url https://gitea.example.com --owner org --repo myoffice_public --push
|
||||||
|
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
script_dir="$(cd "$(dirname "$0")" && pwd)"
|
||||||
|
repo_root="$(cd "$script_dir/.." && pwd)"
|
||||||
|
cd "$repo_root"
|
||||||
|
|
||||||
|
gitea_url="${GITEA_URL:-}"
|
||||||
|
owner="${GITEA_OWNER:-}"
|
||||||
|
repo="${GITEA_REPO:-myoffice-public}"
|
||||||
|
branch="${BRANCH:-master}"
|
||||||
|
token="${GITEA_TOKEN:-}"
|
||||||
|
work_dir="${WORK_DIR:-}"
|
||||||
|
do_push=false
|
||||||
|
include_file="$repo_root/.publishinclude"
|
||||||
|
|
||||||
|
usage() {
|
||||||
|
echo "Usage: $0 --gitea-url URL --owner OWNER [--repo REPO] [--branch BRANCH] [--push]" >&2
|
||||||
|
exit 1
|
||||||
|
}
|
||||||
|
|
||||||
|
while [[ $# -gt 0 ]]; do
|
||||||
|
case "$1" in
|
||||||
|
--gitea-url) gitea_url="$2"; shift 2 ;;
|
||||||
|
--owner) owner="$2"; shift 2 ;;
|
||||||
|
--repo) repo="$2"; shift 2 ;;
|
||||||
|
--branch) branch="$2"; shift 2 ;;
|
||||||
|
--work-dir) work_dir="$2"; shift 2 ;;
|
||||||
|
--push) do_push=true; shift ;;
|
||||||
|
-h|--help) usage ;;
|
||||||
|
*) echo "Unknown argument: $1" >&2; usage ;;
|
||||||
|
esac
|
||||||
|
done
|
||||||
|
|
||||||
|
[[ -n "$gitea_url" && -n "$owner" ]] || usage
|
||||||
|
[[ -f "$include_file" ]] || { echo "Missing $include_file" >&2; exit 1; }
|
||||||
|
|
||||||
|
gitea_url="${gitea_url%/}"
|
||||||
|
[[ -n "$work_dir" ]] || work_dir="${TMPDIR:-/tmp}/myoffice-public-sync"
|
||||||
|
|
||||||
|
# Relative path should not be copied into public tree.
|
||||||
|
should_skip_rel() {
|
||||||
|
local f="$1"
|
||||||
|
local base
|
||||||
|
base="$(basename "$f")"
|
||||||
|
|
||||||
|
# User / policy excludes
|
||||||
|
[[ "$f" == Docker || "$f" == Docker/* ]] && return 0
|
||||||
|
[[ "$base" == .gitignore ]] && return 0
|
||||||
|
[[ "$base" == *.bat ]] && return 0
|
||||||
|
|
||||||
|
# Not required to build or install CT
|
||||||
|
[[ "$f" == MyOffice.Tests || "$f" == MyOffice.Tests/* ]] && return 0
|
||||||
|
[[ "$f" == docs || "$f" == docs/* ]] && return 0
|
||||||
|
[[ "$f" == .gitea || "$f" == .gitea/* ]] && return 0
|
||||||
|
[[ "$base" == .dockerignore ]] && return 0
|
||||||
|
[[ "$base" == gulpfile.js ]] && return 0
|
||||||
|
[[ "$base" == build.ps1 ]] && return 0
|
||||||
|
[[ "$base" == linux_deploy.sh ]] && return 0
|
||||||
|
[[ "$f" == proxmox/README.public.md ]] && return 0
|
||||||
|
|
||||||
|
# Build junk / secrets / Docker-only appsettings
|
||||||
|
[[ "$base" == node_modules || "$f" == */node_modules/* ]] && return 0
|
||||||
|
[[ "$base" == bin || "$f" == */bin/* ]] && return 0
|
||||||
|
[[ "$base" == obj || "$f" == */obj/* ]] && return 0
|
||||||
|
[[ "$base" == .angular || "$f" == */.angular/* ]] && return 0
|
||||||
|
[[ "$base" == _Published || "$f" == _Published/* ]] && return 0
|
||||||
|
[[ "$base" == myoffice-publish.tar.gz ]] && return 0
|
||||||
|
[[ "$base" == .env || "$base" == env.local || "$base" == .env.* ]] && return 0
|
||||||
|
[[ "$base" == appsettings.Docker.json ]] && return 0
|
||||||
|
[[ "$base" == appsettings.shared.Docker.json ]] && return 0
|
||||||
|
[[ "$base" == environment.docker.ts ]] && return 0
|
||||||
|
[[ "$base" == *.user || "$base" == *.suo ]] && return 0
|
||||||
|
|
||||||
|
# SPA unit-test harness (not used by ng build --configuration proxmox)
|
||||||
|
[[ "$base" == karma.conf.js ]] && return 0
|
||||||
|
[[ "$base" == *.spec.ts ]] && return 0
|
||||||
|
|
||||||
|
return 1
|
||||||
|
}
|
||||||
|
|
||||||
|
if [[ -n "$token" ]]; then
|
||||||
|
host_part="${gitea_url#https://}"
|
||||||
|
host_part="${host_part#http://}"
|
||||||
|
scheme=https
|
||||||
|
[[ "$gitea_url" == http://* ]] && scheme=http
|
||||||
|
remote="${scheme}://oauth2:${token}@${host_part}/${owner}/${repo}.git"
|
||||||
|
else
|
||||||
|
remote="${gitea_url}/${owner}/${repo}.git"
|
||||||
|
echo "No token — using remote as-is (SSH URL recommended): $remote"
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "-----------------------------------------------"
|
||||||
|
echo "Sync allowlist (.publishinclude) -> $owner/$repo ($branch)"
|
||||||
|
echo "WorkDir: $work_dir"
|
||||||
|
echo "-----------------------------------------------"
|
||||||
|
|
||||||
|
rm -rf "$work_dir"
|
||||||
|
mkdir -p "$work_dir"
|
||||||
|
|
||||||
|
if ! git clone --depth 1 --branch "$branch" "$remote" "$work_dir" 2>/dev/null; then
|
||||||
|
echo "Clone failed or empty repo — initializing new git repo..."
|
||||||
|
git -C "$work_dir" init -b "$branch" 2>/dev/null || {
|
||||||
|
git -C "$work_dir" init
|
||||||
|
git -C "$work_dir" checkout -b "$branch" 2>/dev/null || true
|
||||||
|
}
|
||||||
|
git -C "$work_dir" remote remove origin 2>/dev/null || true
|
||||||
|
git -C "$work_dir" remote add origin "$remote"
|
||||||
|
fi
|
||||||
|
|
||||||
|
find "$work_dir" -mindepth 1 -maxdepth 1 ! -name '.git' -exec rm -rf {} +
|
||||||
|
|
||||||
|
copied=0
|
||||||
|
skipped=0
|
||||||
|
|
||||||
|
copy_tracked_under() {
|
||||||
|
local prefix="$1" # '' for file, or 'Dir/'
|
||||||
|
while IFS= read -r -d '' f; do
|
||||||
|
if [[ -n "$prefix" ]]; then
|
||||||
|
[[ "$f" == "$prefix"* ]] || continue
|
||||||
|
else
|
||||||
|
[[ "$f" == "$2" ]] || continue
|
||||||
|
fi
|
||||||
|
if should_skip_rel "$f"; then
|
||||||
|
skipped=$((skipped + 1))
|
||||||
|
continue
|
||||||
|
fi
|
||||||
|
src="$repo_root/$f"
|
||||||
|
[[ -e "$src" ]] || continue
|
||||||
|
dest="$work_dir/$f"
|
||||||
|
mkdir -p "$(dirname "$dest")"
|
||||||
|
cp -a "$src" "$dest"
|
||||||
|
copied=$((copied + 1))
|
||||||
|
done < <(git -C "$repo_root" ls-files -z)
|
||||||
|
}
|
||||||
|
|
||||||
|
while IFS= read -r line || [[ -n "$line" ]]; do
|
||||||
|
line="${line%%#*}"
|
||||||
|
line="$(echo "$line" | sed 's/^[[:space:]]*//;s/[[:space:]]*$//')"
|
||||||
|
[[ -z "$line" ]] && continue
|
||||||
|
|
||||||
|
src="$repo_root/$line"
|
||||||
|
if [[ "$line" == */ ]]; then
|
||||||
|
dir="${line%/}"
|
||||||
|
if [[ ! -d "$repo_root/$dir" ]]; then
|
||||||
|
echo "Skip missing dir: $dir"
|
||||||
|
continue
|
||||||
|
fi
|
||||||
|
copy_tracked_under "${dir}/"
|
||||||
|
echo "Synced $dir/"
|
||||||
|
else
|
||||||
|
if [[ ! -e "$src" ]]; then
|
||||||
|
echo "Skip missing file: $line"
|
||||||
|
continue
|
||||||
|
fi
|
||||||
|
if should_skip_rel "$line"; then
|
||||||
|
skipped=$((skipped + 1))
|
||||||
|
echo "Skip excluded: $line"
|
||||||
|
continue
|
||||||
|
fi
|
||||||
|
# Only copy if tracked (or exists — LICENSE etc.)
|
||||||
|
dest="$work_dir/$line"
|
||||||
|
mkdir -p "$(dirname "$dest")"
|
||||||
|
cp -a "$src" "$dest"
|
||||||
|
copied=$((copied + 1))
|
||||||
|
echo "Synced $line"
|
||||||
|
fi
|
||||||
|
done < "$include_file"
|
||||||
|
|
||||||
|
echo "Copied $copied files (skipped $skipped)."
|
||||||
|
|
||||||
|
repo_raw_base="${gitea_url}/${owner}/${repo}/raw/branch/${branch}"
|
||||||
|
readme_template="$script_dir/README.public.md"
|
||||||
|
[[ -f "$readme_template" ]] || { echo "Missing $readme_template" >&2; exit 1; }
|
||||||
|
sed \
|
||||||
|
-e "s|__GITEA_URL__|${gitea_url}|g" \
|
||||||
|
-e "s|__GITEA_OWNER__|${owner}|g" \
|
||||||
|
-e "s|__GITEA_REPO__|${repo}|g" \
|
||||||
|
-e "s|__GITEA_BRANCH__|${branch}|g" \
|
||||||
|
-e "s|__REPO_RAW_BASE__|${repo_raw_base}|g" \
|
||||||
|
"$readme_template" >"$work_dir/README.md"
|
||||||
|
echo "Wrote public README.md"
|
||||||
|
|
||||||
|
# Public consumers need a minimal .gitignore so git status stays clean after npm/dotnet restore
|
||||||
|
cat >"$work_dir/.gitignore" <<'EOF'
|
||||||
|
**/bin/
|
||||||
|
**/obj/
|
||||||
|
**/node_modules/
|
||||||
|
**/.angular/
|
||||||
|
_Published/
|
||||||
|
myoffice-publish.tar.gz
|
||||||
|
*.user
|
||||||
|
.env
|
||||||
|
env.local
|
||||||
|
MyOffice.SPA/src/environments/environment.ts
|
||||||
|
MyOffice.Shared/appsettings.shared.Development.json
|
||||||
|
MyOffice.Shared/appsettings.shared.Production.json
|
||||||
|
EOF
|
||||||
|
|
||||||
|
git -C "$work_dir" add -A
|
||||||
|
if [[ -z "$(git -C "$work_dir" status --porcelain)" ]]; then
|
||||||
|
echo "No changes to commit."
|
||||||
|
else
|
||||||
|
git -C "$work_dir" -c user.email='myoffice-sync@local' -c user.name='myoffice-sync' \
|
||||||
|
commit -m "sync build tree from private myoffice"
|
||||||
|
echo "Committed public snapshot."
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [[ "$do_push" == true ]]; then
|
||||||
|
echo "Pushing to origin $branch..."
|
||||||
|
git -C "$work_dir" push -u origin "HEAD:$branch"
|
||||||
|
echo "Pushed."
|
||||||
|
else
|
||||||
|
echo "Dry run (no push). Re-run with --push to publish."
|
||||||
|
echo "Work tree left at: $work_dir"
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo ""
|
||||||
|
echo "Proxmox one-liner:"
|
||||||
|
echo " bash -c \"\$(curl -fsSL ${repo_raw_base}/proxmox/myoffice.sh)\""
|
||||||
|
echo ""
|
||||||
@@ -0,0 +1,60 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# Helpers inside an existing MyOffice LXC (runtime + Postgres + Gitea release app).
|
||||||
|
# Usage:
|
||||||
|
# /opt/myoffice/proxmox/update.sh # download latest/tag release + restart
|
||||||
|
# /opt/myoffice/proxmox/update.sh upgrade
|
||||||
|
# /opt/myoffice/proxmox/update.sh logs
|
||||||
|
# /opt/myoffice/proxmox/update.sh restart
|
||||||
|
# /opt/myoffice/proxmox/update.sh status
|
||||||
|
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
|
||||||
|
APP_DIR="$(cd "${SCRIPT_DIR}/.." && pwd)"
|
||||||
|
API_DIR="${APP_DIR}/api"
|
||||||
|
ACTION="${1:-upgrade}"
|
||||||
|
|
||||||
|
# shellcheck disable=SC1091
|
||||||
|
[[ -f "${SCRIPT_DIR}/env" ]] && . "${SCRIPT_DIR}/env"
|
||||||
|
|
||||||
|
export APP_DIR API_DIR
|
||||||
|
export GITEA_URL="${GITEA_URL:-}"
|
||||||
|
export GITEA_OWNER="${GITEA_OWNER:-}"
|
||||||
|
export GITEA_REPO="${GITEA_REPO:-myoffice_public}"
|
||||||
|
export RELEASE_TAG="${RELEASE_TAG:-latest}"
|
||||||
|
export RELEASE_ASSET_URL="${RELEASE_ASSET_URL:-}"
|
||||||
|
export GITEA_TOKEN="${GITEA_TOKEN:-}"
|
||||||
|
|
||||||
|
upgrade_app() {
|
||||||
|
systemctl stop myoffice-api 2>/dev/null || true
|
||||||
|
# shellcheck disable=SC1091
|
||||||
|
source "${SCRIPT_DIR}/fetch-release.sh"
|
||||||
|
download_and_extract
|
||||||
|
systemctl start myoffice-api
|
||||||
|
systemctl status myoffice-api --no-pager || true
|
||||||
|
}
|
||||||
|
|
||||||
|
case "$ACTION" in
|
||||||
|
upgrade | update | rebuild | up)
|
||||||
|
upgrade_app
|
||||||
|
;;
|
||||||
|
logs)
|
||||||
|
journalctl -u myoffice-api -f
|
||||||
|
;;
|
||||||
|
restart)
|
||||||
|
systemctl restart myoffice-api
|
||||||
|
systemctl status myoffice-api --no-pager
|
||||||
|
;;
|
||||||
|
status)
|
||||||
|
systemctl status myoffice-api --no-pager || true
|
||||||
|
if [[ ! -f "${API_DIR}/MyOffice.Web.dll" ]]; then
|
||||||
|
echo
|
||||||
|
echo "No app binaries. Run: $0 upgrade"
|
||||||
|
echo "Or set GITEA_URL / GITEA_OWNER / RELEASE_TAG in ${SCRIPT_DIR}/env"
|
||||||
|
fi
|
||||||
|
;;
|
||||||
|
*)
|
||||||
|
echo "Usage: $0 [upgrade|status|logs|restart]" >&2
|
||||||
|
exit 1
|
||||||
|
;;
|
||||||
|
esac
|
||||||
Reference in New Issue
Block a user