Fixed connection retry to local Syncthing.
[src/xds/xds-server.git] / webapp / src / app / common / syncthing.service.ts
1 import { Injectable } from '@angular/core';
2 import { Http, Headers, RequestOptionsArgs, Response } from '@angular/http';
3 import { Location } from '@angular/common';
4 import { Observable } from 'rxjs/Observable';
5 import { BehaviorSubject } from 'rxjs/BehaviorSubject';
6
7 // Import RxJs required methods
8 import 'rxjs/add/operator/map';
9 import 'rxjs/add/operator/catch';
10 import 'rxjs/add/observable/throw';
11 import 'rxjs/add/observable/of';
12 import 'rxjs/add/observable/timer';
13 import 'rxjs/add/operator/retryWhen';
14
15 export interface ISyncThingProject {
16     id: string;
17     path: string;
18     remoteSyncThingID: string;
19     label?: string;
20 }
21
22 export interface ISyncThingStatus {
23     ID: string;
24     baseURL: string;
25     connected: boolean;
26     connectionRetry: number;
27     tilde: string;
28     rawStatus: any;
29 }
30
31 // Private interfaces of Syncthing
32 const ISTCONFIG_VERSION = 19;
33
34 interface ISTFolderDeviceConfiguration {
35     deviceID: string;
36     introducedBy: string;
37 }
38 interface ISTFolderConfiguration {
39     id: string;
40     label: string;
41     path: string;
42     type?: number;
43     devices?: ISTFolderDeviceConfiguration[];
44     rescanIntervalS?: number;
45     ignorePerms?: boolean;
46     autoNormalize?: boolean;
47     minDiskFreePct?: number;
48     versioning?: { type: string; params: string[] };
49     copiers?: number;
50     pullers?: number;
51     hashers?: number;
52     order?: number;
53     ignoreDelete?: boolean;
54     scanProgressIntervalS?: number;
55     pullerSleepS?: number;
56     pullerPauseS?: number;
57     maxConflicts?: number;
58     disableSparseFiles?: boolean;
59     disableTempIndexes?: boolean;
60     fsync?: boolean;
61     paused?: boolean;
62 }
63
64 interface ISTDeviceConfiguration {
65     deviceID: string;
66     name?: string;
67     address?: string[];
68     compression?: string;
69     certName?: string;
70     introducer?: boolean;
71     skipIntroductionRemovals?: boolean;
72     introducedBy?: string;
73     paused?: boolean;
74     allowedNetwork?: string[];
75 }
76
77 interface ISTGuiConfiguration {
78     enabled: boolean;
79     address: string;
80     user?: string;
81     password?: string;
82     useTLS: boolean;
83     apiKey?: string;
84     insecureAdminAccess?: boolean;
85     theme: string;
86     debugging: boolean;
87     insecureSkipHostcheck?: boolean;
88 }
89
90 interface ISTOptionsConfiguration {
91     listenAddresses: string[];
92     globalAnnounceServer: string[];
93     // To be completed ...
94 }
95
96 interface ISTConfiguration {
97     version: number;
98     folders: ISTFolderConfiguration[];
99     devices: ISTDeviceConfiguration[];
100     gui: ISTGuiConfiguration;
101     options: ISTOptionsConfiguration;
102     ignoredDevices: string[];
103 }
104
105 // Default settings
106 const DEFAULT_GUI_PORT = 8384;
107 const DEFAULT_GUI_API_KEY = "1234abcezam";
108
109
110 @Injectable()
111 export class SyncthingService {
112
113     public Status$: Observable<ISyncThingStatus>;
114
115     private baseRestUrl: string;
116     private apikey: string;
117     private localSTID: string;
118     private stCurVersion: number;
119     private connectionMaxRetry: number;
120     private _status: ISyncThingStatus = {
121         ID: null,
122         baseURL: "",
123         connected: false,
124         connectionRetry: 0,
125         tilde: "",
126         rawStatus: null,
127     };
128     private statusSubject = <BehaviorSubject<ISyncThingStatus>>new BehaviorSubject(this._status);
129
130     constructor(private http: Http, private _window: Window) {
131         this._status.baseURL = 'http://localhost:' + DEFAULT_GUI_PORT;
132         this.baseRestUrl = this._status.baseURL + '/rest';
133         this.apikey = DEFAULT_GUI_API_KEY;
134         this.stCurVersion = -1;
135         this.connectionMaxRetry = 10;   // 10 seconds
136
137         this.Status$ = this.statusSubject.asObservable();
138     }
139
140     connect(retry: number, url?: string): Observable<ISyncThingStatus> {
141         if (url) {
142             this._status.baseURL = url;
143             this.baseRestUrl = this._status.baseURL + '/rest';
144         }
145         this._status.connected = false;
146         this._status.ID = null;
147         this._status.connectionRetry = 0;
148         this.connectionMaxRetry = retry || 3600;   // 1 hour
149         return this.getStatus();
150     }
151
152     getID(): Observable<string> {
153         if (this._status.ID != null) {
154             return Observable.of(this._status.ID);
155         }
156         return this.getStatus().map(sts => sts.ID);
157     }
158
159     getStatus(): Observable<ISyncThingStatus> {
160         return this._get('/system/status')
161             .map((status) => {
162                 this._status.ID = status["myID"];
163                 this._status.tilde = status["tilde"];
164                 console.debug('ST local ID', this._status.ID);
165
166                 this._status.rawStatus = status;
167
168                 return this._status;
169             });
170     }
171
172     getProjects(): Observable<ISTFolderConfiguration[]> {
173         return this._getConfig()
174             .map((conf) => conf.folders);
175     }
176
177     addProject(prj: ISyncThingProject): Observable<ISTFolderConfiguration> {
178         return this.getID()
179             .flatMap(() => this._getConfig())
180             .flatMap((stCfg) => {
181                 let newDevID = prj.remoteSyncThingID;
182
183                 // Add new Device if needed
184                 let dev = stCfg.devices.filter(item => item.deviceID === newDevID);
185                 if (dev.length <= 0) {
186                     stCfg.devices.push(
187                         {
188                             deviceID: newDevID,
189                             name: "Builder_" + newDevID.slice(0, 15),
190                             address: ["dynamic"],
191                         }
192                     );
193                 }
194
195                 // Add or update Folder settings
196                 let label = prj.label || "";
197                 let folder: ISTFolderConfiguration = {
198                     id: prj.id,
199                     label: label,
200                     path: prj.path,
201                     devices: [{ deviceID: newDevID, introducedBy: "" }],
202                     autoNormalize: true,
203                 };
204
205                 let idx = stCfg.folders.findIndex(item => item.id === prj.id);
206                 if (idx === -1) {
207                     stCfg.folders.push(folder);
208                 } else {
209                     let newFld = Object.assign({}, stCfg.folders[idx], folder);
210                     stCfg.folders[idx] = newFld;
211                 }
212
213                 // Set new config
214                 return this._setConfig(stCfg);
215             })
216             .flatMap(() => this._getConfig())
217             .map((newConf) => {
218                 let idx = newConf.folders.findIndex(item => item.id === prj.id);
219                 return newConf.folders[idx];
220             });
221     }
222
223     deleteProject(id: string): Observable<ISTFolderConfiguration> {
224         let delPrj: ISTFolderConfiguration;
225         return this._getConfig()
226             .flatMap((conf: ISTConfiguration) => {
227                 let idx = conf.folders.findIndex(item => item.id === id);
228                 if (idx === -1) {
229                     throw new Error("Cannot delete project: not found");
230                 }
231                 delPrj = Object.assign({}, conf.folders[idx]);
232                 conf.folders.splice(idx, 1);
233                 return this._setConfig(conf);
234             })
235             .map(() => delPrj);
236     }
237
238     /*
239      * --- Private functions ---
240      */
241     private _getConfig(): Observable<ISTConfiguration> {
242         return this._get('/system/config');
243     }
244
245     private _setConfig(cfg: ISTConfiguration): Observable<any> {
246         return this._post('/system/config', cfg);
247     }
248
249     private _attachAuthHeaders(options?: any) {
250         options = options || {};
251         let headers = options.headers || new Headers();
252         // headers.append('Authorization', 'Basic ' + btoa('username:password'));
253         headers.append('Accept', 'application/json');
254         headers.append('Content-Type', 'application/json');
255         if (this.apikey !== "") {
256             headers.append('X-API-Key', this.apikey);
257
258         }
259         options.headers = headers;
260         return options;
261     }
262
263     private _checkAlive(): Observable<boolean> {
264         if (this._status.connected) {
265             return Observable.of(true);
266         }
267
268         return this.http.get(this.baseRestUrl + '/system/version', this._attachAuthHeaders())
269             .map((r) => this._status.connected = true)
270             .retryWhen((attempts) => {
271                 this._status.connectionRetry = 0;
272                 return attempts.flatMap(error => {
273                     this._status.connected = false;
274                     if (++this._status.connectionRetry >= this.connectionMaxRetry) {
275                         return Observable.throw("Syncthing local daemon not responding (url=" + this._status.baseURL + ")");
276                     } else {
277                         return Observable.timer(1000);
278                     }
279                 });
280             });
281     }
282
283     private _getAPIVersion(): Observable<number> {
284         if (this.stCurVersion !== -1) {
285             return Observable.of(this.stCurVersion);
286         }
287
288         return this.http.get(this.baseRestUrl + '/system/config', this._attachAuthHeaders())
289             .map((res: Response) => {
290                 let conf: ISTConfiguration = res.json();
291                 this.stCurVersion = (conf && conf.version) || -1;
292                 return this.stCurVersion;
293             })
294             .catch(this._handleError);
295     }
296
297     private _checkAPIVersion(): Observable<number> {
298         return this._getAPIVersion().map(ver => {
299             if (ver !== ISTCONFIG_VERSION) {
300                 throw new Error("Unsupported Syncthing version api (" + ver +
301                     " != " + ISTCONFIG_VERSION + ") !");
302             }
303             return ver;
304         });
305     }
306
307     private _get(url: string): Observable<any> {
308         return this._checkAlive()
309             .flatMap(() => this._checkAPIVersion())
310             .flatMap(() => this.http.get(this.baseRestUrl + url, this._attachAuthHeaders()))
311             .map((res: Response) => res.json())
312             .catch(this._handleError);
313     }
314
315     private _post(url: string, body: any): Observable<any> {
316         return this._checkAlive()
317             .flatMap(() => this._checkAPIVersion())
318             .flatMap(() => this.http.post(this.baseRestUrl + url, JSON.stringify(body), this._attachAuthHeaders()))
319             .map((res: Response) => {
320                 if (res && res.status && res.status === 200) {
321                     return res;
322                 }
323                 throw new Error(res.toString());
324
325             })
326             .catch(this._handleError);
327     }
328
329     private _handleError(error: Response | any) {
330         // In a real world app, you might use a remote logging infrastructure
331         let errMsg: string;
332         if (this._status) {
333             this._status.connected = false;
334         }
335         if (error instanceof Response) {
336             const body = error.json() || 'Server error';
337             const err = body.error || JSON.stringify(body);
338             errMsg = `${error.status} - ${error.statusText || ''} ${err}`;
339         } else {
340             errMsg = error.message ? error.message : error.toString();
341         }
342         return Observable.throw(errMsg);
343     }
344 }