13 Commits
62 changed files with 2557 additions and 1139 deletions
+12
View File
@@ -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
View File
@@ -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.
-42
View File
@@ -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
-45
View File
@@ -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
});
};
+1 -1
View File
@@ -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!');
});
});
+20 -2
View File
@@ -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',
}; };
} }
} }
+2 -1
View File
@@ -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 = {
+1 -4
View File
@@ -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"
}
}
-65
View File
@@ -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"));
}
}
-173
View File
@@ -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);
}
}
}
-29
View File
@@ -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()
-31
View File
@@ -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
}
}
+1
View File
@@ -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
+53
View File
@@ -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)
+149
View File
@@ -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`
+138
View File
@@ -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 ""
+157
View File
@@ -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
+245
View File
@@ -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
+19
View File
@@ -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
+565
View File
@@ -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
+166
View File
@@ -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 ""
+141
View File
@@ -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 ""
+154
View File
@@ -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 ""
+158
View File
@@ -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 ""
+221
View File
@@ -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 ""
+228
View File
@@ -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 ""
+60
View File
@@ -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