Revision | 119 (tree) |
---|---|
Time | 2022-10-26 18:01:41 |
Author | ![]() |
Firebase Hosting へのアップロードに対応しました。
@@ -648,7 +648,7 @@ | ||
648 | 648 | } |
649 | 649 | } |
650 | 650 | |
651 | - private static Path createTemporaryDirectory(String dir, boolean isDeleteIfExists) throws IOException { | |
651 | + public static Path createTemporaryDirectory(String dir, boolean isDeleteIfExists) throws IOException { | |
652 | 652 | Path path = Paths.get(System.getProperty("java.io.tmpdir")) |
653 | 653 | .resolve("catalpa") |
654 | 654 | .resolve(dir); |
@@ -0,0 +1,62 @@ | ||
1 | +package net.osdn.catalpa.upload.firebase; | |
2 | + | |
3 | +import net.osdn.catalpa.ProgressObserver; | |
4 | +import net.osdn.catalpa.upload.UploadConfig; | |
5 | + | |
6 | +import java.io.File; | |
7 | +import java.io.FileNotFoundException; | |
8 | +import java.io.UncheckedIOException; | |
9 | +import java.nio.file.Files; | |
10 | +import java.nio.file.Path; | |
11 | +import java.nio.file.Paths; | |
12 | + | |
13 | +public class FirebaseConfig extends UploadConfig { | |
14 | + | |
15 | + private String siteId; | |
16 | + private Path secretKeyFilePath; | |
17 | + | |
18 | + public FirebaseConfig() { | |
19 | + } | |
20 | + | |
21 | + public String getSiteId() { | |
22 | + return this.siteId; | |
23 | + } | |
24 | + | |
25 | + public Path getSecretKeyFilePath() { | |
26 | + return this.secretKeyFilePath; | |
27 | + } | |
28 | + | |
29 | + private void initialize() { | |
30 | + String s; | |
31 | + | |
32 | + s = getValueAsString("siteId"); | |
33 | + if(s == null) { | |
34 | + throw new RuntimeException("siteId not found"); | |
35 | + } | |
36 | + this.siteId = s.trim(); | |
37 | + | |
38 | + s = getValueAsString("secretKey"); | |
39 | + if(s != null) { | |
40 | + String secretKey = s.replace('/', '\\'); | |
41 | + if(secretKey.length() >= 3 && secretKey.substring(1, 3).equals(":\\")) { | |
42 | + this.secretKeyFilePath = Paths.get(secretKey).toAbsolutePath(); | |
43 | + } else { | |
44 | + Path p = getFolderPath("secretKey"); | |
45 | + this.secretKeyFilePath = p.resolve(secretKey).toAbsolutePath(); | |
46 | + } | |
47 | + if(!Files.exists(this.secretKeyFilePath)) { | |
48 | + throw new UncheckedIOException(new FileNotFoundException(this.secretKeyFilePath.toString())); | |
49 | + } | |
50 | + } | |
51 | + } | |
52 | + | |
53 | + public int upload(File dir, ProgressObserver observer) throws Exception { | |
54 | + initialize(); | |
55 | + | |
56 | + FirebaseUploader uploader = new FirebaseUploader(this); | |
57 | + int count = 0; | |
58 | + count = uploader.upload(dir, observer); | |
59 | + | |
60 | + return count; | |
61 | + } | |
62 | +} |
@@ -0,0 +1,287 @@ | ||
1 | +package net.osdn.catalpa.upload.firebase; | |
2 | + | |
3 | +import com.fasterxml.jackson.databind.ObjectMapper; | |
4 | +import com.google.auth.oauth2.AccessToken; | |
5 | +import com.google.auth.oauth2.GoogleCredentials; | |
6 | +import net.osdn.catalpa.ProgressObserver; | |
7 | +import net.osdn.catalpa.ui.javafx.MainApp; | |
8 | +import net.osdn.catalpa.ui.javafx.ToastMessage; | |
9 | + | |
10 | +import java.io.File; | |
11 | +import java.io.IOException; | |
12 | +import java.io.InputStream; | |
13 | +import java.io.OutputStream; | |
14 | +import java.math.BigInteger; | |
15 | +import java.net.URI; | |
16 | +import java.net.http.HttpClient; | |
17 | +import java.net.http.HttpRequest; | |
18 | +import java.net.http.HttpResponse; | |
19 | +import java.nio.charset.StandardCharsets; | |
20 | +import java.nio.file.Files; | |
21 | +import java.nio.file.Path; | |
22 | +import java.security.MessageDigest; | |
23 | +import java.security.NoSuchAlgorithmException; | |
24 | +import java.util.HashMap; | |
25 | +import java.util.LinkedHashMap; | |
26 | +import java.util.List; | |
27 | +import java.util.Map; | |
28 | +import java.util.stream.Stream; | |
29 | +import java.util.zip.Deflater; | |
30 | +import java.util.zip.GZIPOutputStream; | |
31 | + | |
32 | +/* Hosting REST API を使用してサイトにデプロイする | |
33 | + * https://firebase.google.com/docs/hosting/api-deploy?hl=ja#raw-https-request_4 | |
34 | + * | |
35 | + */ | |
36 | +public class FirebaseUploader { | |
37 | + | |
38 | + private FirebaseConfig config; | |
39 | + private ProgressObserver observer; | |
40 | + private int progress; | |
41 | + private int maxProgress; | |
42 | + | |
43 | + public FirebaseUploader(FirebaseConfig config) { | |
44 | + this.config = config; | |
45 | + } | |
46 | + | |
47 | + public int upload(File localDirectory, ProgressObserver observer) throws IOException, InterruptedException, NoSuchAlgorithmException { | |
48 | + this.observer = (observer != null) ? observer : ProgressObserver.EMPTY; | |
49 | + this.observer.setProgress(0.0); | |
50 | + this.observer.setText("アップロードの準備をしています…"); | |
51 | + | |
52 | + int uploadCount = 0; | |
53 | + | |
54 | + String siteId = config.getSiteId(); | |
55 | + if(siteId == null) { | |
56 | + throw new ToastMessage("Firebase Hosting", "siteId が指定されていません"); | |
57 | + } | |
58 | + | |
59 | + Path secretKeyFilePath = config.getSecretKeyFilePath(); | |
60 | + if(secretKeyFilePath == null) { | |
61 | + throw new ToastMessage("Firebase Hosting", "secretKeyFilePath が指定されていません"); | |
62 | + } | |
63 | + | |
64 | + Path input = localDirectory.toPath(); | |
65 | + Path output = MainApp.createTemporaryDirectory("upload-htdocs-gzipped", true); | |
66 | + | |
67 | + MessageDigest sha256 = MessageDigest.getInstance("SHA-256"); | |
68 | + HttpClient client = HttpClient.newBuilder() | |
69 | + .version(HttpClient.Version.HTTP_2) | |
70 | + .followRedirects(HttpClient.Redirect.NORMAL) | |
71 | + .build(); | |
72 | + | |
73 | + String token = getAccessToken(secretKeyFilePath); | |
74 | + | |
75 | + String versionId = createVersionId(client, token, siteId); | |
76 | + | |
77 | + Map<Path, String> files = new LinkedHashMap<>(); | |
78 | + int len; | |
79 | + byte[] buf = new byte[65536]; | |
80 | + try(Stream<Path> stream = Files.walk(input)) { | |
81 | + List<Path> list = stream.toList(); | |
82 | + for(Path path : list) { | |
83 | + if(Files.isDirectory(path)) { | |
84 | + Path dirname = input.relativize(path); | |
85 | + Files.createDirectories(output.resolve(dirname)); | |
86 | + } else { | |
87 | + Path file = input.relativize(path); | |
88 | + try(InputStream in = Files.newInputStream(path); | |
89 | + OutputStream out = Files.newOutputStream(output.resolve(file)); | |
90 | + GZIPOutputStream gzOut = new GZIPOutputStream(out) {{ def.setLevel(Deflater.BEST_SPEED); }}) { | |
91 | + while((len = in.read(buf)) > 0) { | |
92 | + gzOut.write(buf, 0, len); | |
93 | + } | |
94 | + gzOut.finish(); | |
95 | + } | |
96 | + String hash = getSHA256(sha256, output.resolve(file)); | |
97 | + files.put(file, hash); | |
98 | + } | |
99 | + } | |
100 | + } | |
101 | + | |
102 | + PopulateFilesResult populateFilesResult = populateFiles(client, token, siteId, versionId, files); | |
103 | + | |
104 | + if(populateFilesResult.uploadRequiredHashes != null) { | |
105 | + progress = 0; | |
106 | + maxProgress = populateFilesResult.uploadRequiredHashes.size(); | |
107 | + | |
108 | + Map<String, Path> map = new HashMap<>(); | |
109 | + for(Map.Entry<Path, String> entry : files.entrySet()) { | |
110 | + map.put(entry.getValue(), entry.getKey()); | |
111 | + } | |
112 | + for(String uploadRequiredhash : populateFilesResult.uploadRequiredHashes) { | |
113 | + Path file = map.get(uploadRequiredhash); | |
114 | + String url = populateFilesResult.uploadUrl + "/" + uploadRequiredhash; | |
115 | + | |
116 | + this.observer.setProgress(++progress / (double)maxProgress); | |
117 | + this.observer.setText("/" + file.toString().replace('\\', '/')); | |
118 | + | |
119 | + upload(client, token, url, output.resolve(file)); | |
120 | + uploadCount++; | |
121 | + } | |
122 | + } | |
123 | + | |
124 | + finalizeVersion(client, token, siteId, versionId); | |
125 | + releaseVersion(client, token, siteId, versionId); | |
126 | + | |
127 | + return uploadCount; | |
128 | + } | |
129 | + | |
130 | + | |
131 | + private static String getAccessToken(Path secretKeyJsonFile) throws IOException { | |
132 | + try(InputStream in = Files.newInputStream(secretKeyJsonFile)) { | |
133 | + GoogleCredentials credential = GoogleCredentials | |
134 | + .fromStream(in) | |
135 | + .createScoped("https://www.googleapis.com/auth/firebase"); | |
136 | + | |
137 | + AccessToken token = credential.refreshAccessToken(); | |
138 | + return token.getTokenValue(); | |
139 | + } | |
140 | + } | |
141 | + | |
142 | + | |
143 | + private static String createVersionId(HttpClient client, String token, String siteId) throws IOException, InterruptedException { | |
144 | + String versionId = null; | |
145 | + | |
146 | + String url = "https://firebasehosting.googleapis.com" + "/v1beta1/sites/" + siteId + "/versions"; | |
147 | + HttpRequest request = HttpRequest.newBuilder(URI.create(url)) | |
148 | + .setHeader("Authorization", "Bearer " + token) | |
149 | + .setHeader("Content-Type", "application/json") | |
150 | + .POST(HttpRequest.BodyPublishers.ofString(""" | |
151 | + { | |
152 | + "config": { | |
153 | + "headers": [{ | |
154 | + "glob": "**", | |
155 | + "headers": { | |
156 | + "cache-Control": "max-age=1800" | |
157 | + } | |
158 | + }] | |
159 | + } | |
160 | + } | |
161 | + """, StandardCharsets.UTF_8)) | |
162 | + .build(); | |
163 | + | |
164 | + HttpResponse<String> response = client.send(request, HttpResponse.BodyHandlers.ofString()); | |
165 | + | |
166 | + if(response.statusCode() != 200) { | |
167 | + throw new IOException(response + "\n" + response.body()); | |
168 | + } | |
169 | + | |
170 | + CreateVersionResult result = new ObjectMapper().readValue(response.body(), CreateVersionResult.class); | |
171 | + if(result.status.equals("CREATED")) { | |
172 | + int i = result.name.indexOf("/versions/"); | |
173 | + versionId = result.name.substring(i + "/versions/".length()); | |
174 | + } | |
175 | + return versionId; | |
176 | + } | |
177 | + | |
178 | + | |
179 | + private static PopulateFilesResult populateFiles(HttpClient client, String token, String siteId, String versionId, Map<Path, String> files) throws IOException, InterruptedException { | |
180 | + StringBuilder sb = new StringBuilder(); | |
181 | + sb.append("{\n"); | |
182 | + sb.append(" \"files\": {\n"); | |
183 | + | |
184 | + for(Map.Entry<Path, String> entry : files.entrySet()) { | |
185 | + Path file = entry.getKey(); | |
186 | + String hash = entry.getValue(); | |
187 | + sb.append(" \"/" + file.toString().replace('\\', '/') + "\": "); | |
188 | + sb.append("\"" + hash + "\""); | |
189 | + sb.append(",\n"); | |
190 | + } | |
191 | + if(files.size() > 0) { | |
192 | + sb.delete(sb.length() - 2, sb.length()); | |
193 | + } | |
194 | + sb.append(" }\n"); | |
195 | + sb.append("}\n"); | |
196 | + | |
197 | + String url = "https://firebasehosting.googleapis.com" + "/v1beta1/sites/" + siteId + "/versions/" + versionId + ":populateFiles"; | |
198 | + HttpRequest request = HttpRequest.newBuilder(URI.create(url)) | |
199 | + .setHeader("Authorization", "Bearer " + token) | |
200 | + .setHeader("Content-Type", "application/json") | |
201 | + .POST(HttpRequest.BodyPublishers.ofString(sb.toString(), StandardCharsets.UTF_8)) | |
202 | + .build(); | |
203 | + | |
204 | + HttpResponse<String> response = client.send(request, HttpResponse.BodyHandlers.ofString()); | |
205 | + | |
206 | + if(response.statusCode() != 200) { | |
207 | + throw new IOException(response + "\n" + response.body()); | |
208 | + } | |
209 | + | |
210 | + PopulateFilesResult result = new ObjectMapper().readValue(response.body(), PopulateFilesResult.class); | |
211 | + return result; | |
212 | + } | |
213 | + | |
214 | + | |
215 | + private static void upload(HttpClient client, String token, String url, Path path) throws IOException, InterruptedException { | |
216 | + HttpRequest request = HttpRequest.newBuilder(URI.create(url)) | |
217 | + .setHeader("Authorization", "Bearer " + token) | |
218 | + .setHeader("Content-Type", "application/octet-stream") | |
219 | + .POST(HttpRequest.BodyPublishers.ofFile(path)) | |
220 | + .build(); | |
221 | + | |
222 | + HttpResponse<String> response = client.send(request, HttpResponse.BodyHandlers.ofString()); | |
223 | + | |
224 | + if(response.statusCode() != 200) { | |
225 | + throw new IOException(response + "\n" + response.body()); | |
226 | + } | |
227 | + } | |
228 | + | |
229 | + | |
230 | + private static void finalizeVersion(HttpClient client, String token, String siteId, String versionId) throws IOException, InterruptedException { | |
231 | + String url = "https://firebasehosting.googleapis.com" + "/v1beta1/sites/" + siteId + "/versions/" + versionId + "?update_mask=status"; | |
232 | + HttpRequest request = HttpRequest.newBuilder(URI.create(url)) | |
233 | + .setHeader("Authorization", "Bearer " + token) | |
234 | + .setHeader("Content-Type", "application/json") | |
235 | + .method("PATCH", HttpRequest.BodyPublishers.ofString("{\"status\": \"FINALIZED\"}")) | |
236 | + .build(); | |
237 | + | |
238 | + HttpResponse<String> response = client.send(request, HttpResponse.BodyHandlers.ofString()); | |
239 | + | |
240 | + if(response.statusCode() != 200) { | |
241 | + throw new IOException(response + "\n" + response.body()); | |
242 | + } | |
243 | + } | |
244 | + | |
245 | + | |
246 | + private static void releaseVersion(HttpClient client, String token, String siteId, String versionId) throws IOException, InterruptedException { | |
247 | + String url = "https://firebasehosting.googleapis.com" + "/v1beta1/sites/" + siteId + "/releases?versionName=sites/" + siteId + "/versions/" + versionId; | |
248 | + HttpRequest request = HttpRequest.newBuilder(URI.create(url)) | |
249 | + .setHeader("Authorization", "Bearer " + token) | |
250 | + .setHeader("Content-Type", "application/json") | |
251 | + .POST(HttpRequest.BodyPublishers.noBody()) | |
252 | + .build(); | |
253 | + | |
254 | + HttpResponse<String> response = client.send(request, HttpResponse.BodyHandlers.ofString()); | |
255 | + | |
256 | + if(response.statusCode() != 200) { | |
257 | + throw new IOException(response + "\n" + response.body()); | |
258 | + } | |
259 | + } | |
260 | + | |
261 | + | |
262 | + private static String getSHA256(MessageDigest sha256, Path path) throws IOException, NoSuchAlgorithmException { | |
263 | + sha256.reset(); | |
264 | + | |
265 | + try(InputStream in = Files.newInputStream(path)) { | |
266 | + byte[] buf = new byte[65536]; | |
267 | + int len; | |
268 | + while((len = in.read(buf)) != -1) { | |
269 | + sha256.update(buf, 0, len); | |
270 | + } | |
271 | + } | |
272 | + return String.format("%040x", new BigInteger(1, sha256.digest())); | |
273 | + } | |
274 | + | |
275 | + | |
276 | + static class CreateVersionResult { | |
277 | + public String name; | |
278 | + public String status; | |
279 | + public Object config; | |
280 | + } | |
281 | + | |
282 | + | |
283 | + static class PopulateFilesResult { | |
284 | + public List<String> uploadRequiredHashes; | |
285 | + public String uploadUrl; | |
286 | + } | |
287 | +} |
@@ -11,6 +11,7 @@ | ||
11 | 11 | |
12 | 12 | import net.osdn.catalpa.Catalpa; |
13 | 13 | import net.osdn.catalpa.Context; |
14 | +import net.osdn.catalpa.upload.firebase.FirebaseConfig; | |
14 | 15 | import net.osdn.catalpa.upload.netlify.NetlifyConfig; |
15 | 16 | import net.osdn.catalpa.upload.sftp.SftpConfig; |
16 | 17 | import net.osdn.catalpa.upload.smb.SmbConfig; |
@@ -66,7 +67,9 @@ | ||
66 | 67 | |
67 | 68 | String type = UploadConfig.getConfigValueAsString(uploadConfigData, "type"); |
68 | 69 | if(type != null) { |
69 | - if(type.equals("netlify")) { | |
70 | + if(type.equals("firebase")) { | |
71 | + uploadConfig = new FirebaseConfig(); | |
72 | + } else if(type.equals("netlify")) { | |
70 | 73 | uploadConfig = new NetlifyConfig(); |
71 | 74 | } else if(type.equals("sftp")) { |
72 | 75 | uploadConfig = new SftpConfig(); |