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