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