1// Copyright (C) 2018 The Android Open Source Project
2//
3// Licensed under the Apache License, Version 2.0 (the "License");
4// you may not use this file except in compliance with the License.
5// You may obtain a copy of the License at
6//
7//      http://www.apache.org/licenses/LICENSE-2.0
8//
9// Unless required by applicable law or agreed to in writing, software
10// distributed under the License is distributed on an "AS IS" BASIS,
11// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12// See the License for the specific language governing permissions and
13// limitations under the License.
14
15import {Draft, produce} from 'immer';
16import * as uuidv4 from 'uuid/v4';
17
18import {assertExists} from '../base/logging';
19import {Actions} from '../common/actions';
20import {EngineConfig, State} from '../common/state';
21
22import {Controller} from './controller';
23import {globals} from './globals';
24
25export const BUCKET_NAME = 'perfetto-ui-data';
26
27export class PermalinkController extends Controller<'main'> {
28  private lastRequestId?: string;
29  constructor() {
30    super('main');
31  }
32
33  run() {
34    if (globals.state.permalink.requestId === undefined ||
35        globals.state.permalink.requestId === this.lastRequestId) {
36      return;
37    }
38    const requestId = assertExists(globals.state.permalink.requestId);
39    this.lastRequestId = requestId;
40
41    // if the |link| is not set, this is a request to create a permalink.
42    if (globals.state.permalink.hash === undefined) {
43      PermalinkController.createPermalink().then(hash => {
44        globals.dispatch(Actions.setPermalink({requestId, hash}));
45      });
46      return;
47    }
48
49    // Otherwise, this is a request to load the permalink.
50    PermalinkController.loadState(globals.state.permalink.hash).then(state => {
51      globals.dispatch(Actions.setState({newState: state}));
52      this.lastRequestId = state.permalink.requestId;
53    });
54  }
55
56  private static async createPermalink() {
57    const state = globals.state;
58
59    // Upload each loaded trace.
60    const fileToUrl = new Map<File, string>();
61    for (const engine of Object.values<EngineConfig>(state.engines)) {
62      if (!(engine.source instanceof File)) continue;
63      PermalinkController.updateStatus(`Uploading ${engine.source.name}`);
64      const url = await this.saveTrace(engine.source);
65      fileToUrl.set(engine.source, url);
66    }
67
68    // Convert state to use URLs and remove permalink.
69    const uploadState = produce(state, draft => {
70      for (const engine of Object.values<Draft<EngineConfig>>(
71               draft.engines)) {
72        if (!(engine.source instanceof File)) continue;
73        engine.source = fileToUrl.get(engine.source)!;
74      }
75      draft.permalink = {};
76    });
77
78    // Upload state.
79    PermalinkController.updateStatus(`Creating permalink...`);
80    const hash = await this.saveState(uploadState);
81    PermalinkController.updateStatus(`Permalink ready`);
82    return hash;
83  }
84
85  private static async saveState(state: State): Promise<string> {
86    const text = JSON.stringify(state);
87    const hash = await this.toSha256(text);
88    const url = 'https://www.googleapis.com/upload/storage/v1/b/' +
89        `${BUCKET_NAME}/o?uploadType=media` +
90        `&name=${hash}&predefinedAcl=publicRead`;
91    const response = await fetch(url, {
92      method: 'post',
93      headers: {
94        'Content-Type': 'application/json; charset=utf-8',
95      },
96      body: text,
97    });
98    await response.json();
99
100    return hash;
101  }
102
103  private static async saveTrace(trace: File): Promise<string> {
104    // TODO(hjd): This should probably also be a hash but that requires
105    // trace processor support.
106    const name = uuidv4();
107    const url = 'https://www.googleapis.com/upload/storage/v1/b/' +
108        `${BUCKET_NAME}/o?uploadType=media` +
109        `&name=${name}&predefinedAcl=publicRead`;
110    const response = await fetch(url, {
111      method: 'post',
112      headers: {'Content-Type': 'application/octet-stream;'},
113      body: trace,
114    });
115    await response.json();
116    return `https://storage.googleapis.com/${BUCKET_NAME}/${name}`;
117  }
118
119  private static async loadState(id: string): Promise<State> {
120    const url = `https://storage.googleapis.com/${BUCKET_NAME}/${id}`;
121    const response = await fetch(url);
122    const text = await response.text();
123    const stateHash = await this.toSha256(text);
124    const state = JSON.parse(text);
125    if (stateHash !== id) {
126      throw new Error(`State hash does not match ${id} vs. ${stateHash}`);
127    }
128    return state;
129  }
130
131  private static async toSha256(str: string): Promise<string> {
132    // TODO(hjd): TypeScript bug with definition of TextEncoder.
133    // tslint:disable-next-line no-any
134    const buffer = new (TextEncoder as any)('utf-8').encode(str);
135    const digest = await crypto.subtle.digest('SHA-256', buffer);
136    return Array.from(new Uint8Array(digest)).map(x => x.toString(16)).join('');
137  }
138
139  private static updateStatus(msg: string): void {
140    // TODO(hjd): Unify loading updates.
141    globals.dispatch(Actions.updateStatus({
142      msg,
143      timestamp: Date.now() / 1000,
144    }));
145  }
146}
147