Skip to content

Commit

Permalink
feat: upload items (#21)
Browse files Browse the repository at this point in the history
* feat(web): upload images

* feat(web): create items

* feat(functions): firebase init

* feat(functions): generate thumbnail

* feat(functions): read exif

* feat(functions): set item date by functions

* feat(functions): get date from EXIF

* feat(functions): add gearId to item

* feat(functions): subscribe items
  • Loading branch information
KazuyaHara authored Nov 6, 2022
1 parent 5e454e8 commit d2d6703
Show file tree
Hide file tree
Showing 44 changed files with 1,912 additions and 210 deletions.
14 changes: 13 additions & 1 deletion firebase.json
Original file line number Diff line number Diff line change
@@ -1,8 +1,20 @@
{
"firestore": { "indexes": "firestore.indexes.json", "rules": "firestore.rules" },
"functions": [
{
"source": "functions",
"codebase": "default",
"ignore": ["node_modules", ".git", "firebase-debug.log", "firebase-debug.*.log"],
"predeploy": [
"npm --prefix \"$RESOURCE_DIR\" run eslint",
"npm --prefix \"$RESOURCE_DIR\" run build"
]
}
],
"hosting": {
"ignore": ["firebase.json", "**/.*", "**/node_modules/**"],
"public": "web/build",
"rewrites": [{ "source": "**", "destination": "/index.html" }]
}
},
"storage": { "rules": "storage.rules" }
}
4 changes: 4 additions & 0 deletions firestore.rules
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,10 @@ service cloud.firestore {
allow list, write: if authenticated();
}

match /items/{itemId} {
allow list, create: if authenticated();
}

match /gears/{gearId} {
allow list, write: if authenticated();
}
Expand Down
12 changes: 12 additions & 0 deletions functions/.eslintrc.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
{
"env": { "es2021": true, "node": true },
"extends": ["airbnb-base", "prettier"],
"parser": "@typescript-eslint/parser",
"parserOptions": { "ecmaVersion": "latest", "sourceType": "module" },
"plugins": ["@typescript-eslint"],
"rules": {
"import/extensions": ["error", "ignorePackages", { "ts": "never" }],
"import/prefer-default-export": "off"
},
"settings": { "import/resolver": { "node": { "extensions": [".ts"] } } }
}
9 changes: 9 additions & 0 deletions functions/.gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
# Compiled JavaScript files
lib/**/*.js
lib/**/*.js.map

# TypeScript v1 declaration files
typings/

# Node.js dependency directory
node_modules/
45 changes: 45 additions & 0 deletions functions/package.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
{
"name": "functions",
"version": "0.4.0",
"private": true,
"main": "lib/index.js",
"scripts": {
"build": "tsc",
"build:dev": "yarn build",
"build:prd": "yarn build",
"build:stg": "yarn build",
"build:watch": "tsc --watch",
"deploy:dev": "firebase use default && firebase deploy --only functions",
"deploy:prd": "firebase use production && firebase deploy --only functions && firebase use default",
"deploy:stg": "firebase use staging && firebase deploy --only functions && firebase use default",
"eslint": "eslint src/**/*.ts",
"logs": "firebase functions:log",
"serve": "npm run build && firebase emulators:start --only functions",
"shell": "npm run build && firebase functions:shell",
"start": "npm run shell"
},
"dependencies": {
"date-fns-tz": "^1.3.7",
"exif-reader": "^1.0.3",
"firebase-admin": "^10.0.2",
"firebase-functions": "^3.18.0",
"mkdirp": "^1.0.4",
"sharp": "^0.31.2"
},
"devDependencies": {
"@types/exif-reader": "^1.0.0",
"@types/mkdirp": "^1.0.2",
"@types/sharp": "^0.31.0",
"@typescript-eslint/eslint-plugin": "^5.42.0",
"@typescript-eslint/parser": "^5.42.0",
"eslint": "^8.9.0",
"eslint-config-airbnb-base": "^15.0.0",
"eslint-config-prettier": "^8.5.0",
"eslint-plugin-import": "^2.26.0",
"firebase-functions-test": "^0.2.0",
"typescript": "^4.5.4"
},
"engines": {
"node": "16"
}
}
5 changes: 5 additions & 0 deletions functions/src/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
import * as admin from 'firebase-admin';

admin.initializeApp();

export { processUploadedMedia } from './storage/media/onFinalize';
146 changes: 146 additions & 0 deletions functions/src/storage/media/onFinalize.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,146 @@
import * as fs from 'fs';
import * as os from 'os';
import * as path from 'path';

import { firestore, storage } from 'firebase-admin';
import { logger, region } from 'firebase-functions';

import { zonedTimeToUtc } from 'date-fns-tz';
import exifReader from 'exif-reader';
import mkdirp from 'mkdirp';
import sharp from 'sharp';

const { increment, serverTimestamp } = firestore.FieldValue;
const getZonedTime = (date?: any) => {
if (!date) return new Date(0);
return zonedTimeToUtc(date, 'Asia/Tokyo');
};

export const processUploadedMedia = region('asia-northeast1')
.storage.object()
.onFinalize(async (object) => {
const { contentType, name } = object;

if (!contentType) return logger.log('File has no Content-Type.');
if (!contentType.startsWith('image/')) return logger.log('This is not an image.');
if (!name) return logger.log('File has no file name.');
const fileName = path.basename(name).split('.')[0];
if (fileName === 'thumbnail') return logger.log('Already a Thumbnail.');

// Download original file from bucket.
const localFile = path.join(os.tmpdir(), name);
await mkdirp(path.dirname(localFile));
const bucket = storage().bucket();
await bucket.file(name).download({ destination: localFile });

// Read exif from original file
const exif = await sharp(localFile)
.metadata()
.then((metadata) => metadata.exif && exifReader(metadata.exif));

// Generate a thumbnail using sharp.
const destinationFileName = 'thumbnail.jpg';
const destination = path.normalize(path.join(path.dirname(name), destinationFileName));
const localThumbnail = path.join(os.tmpdir(), destination);
await sharp(localFile)
.resize(1000)
.toFormat('jpeg')
.toFile(localThumbnail)
.catch((error) => {
logger.error('Error occurred while processing thumbnail');
throw new Error(error);
});

// Upload the thumbnail.
await bucket
.upload(localThumbnail, { destination })
.then(() => logger.info('Thumbnail uploaded.'))
.catch((error) => {
logger.error('Error occurred while uploading thumbnail');
throw new Error(error);
});

// Once the image has been uploaded delete the local files to free up disk space.
fs.unlinkSync(localFile);
fs.unlinkSync(localThumbnail);

// Register gear if needed
const gearName = exif?.image?.Model;
if (!gearName) return logger.log('No model name.');
const gearsRef = firestore().collection('gears');
const gear = await gearsRef
.where('model', '==', gearName)
.limit(1)
.get()
.then((querySnapshot) => {
if (querySnapshot.empty) return null;
return querySnapshot.docs[0];
});
let gearId = gear?.id;
if (gear) {
await gear.ref
.set({ items: increment(1), updatedAt: serverTimestamp() }, { merge: true })
.then(() => logger.info('Gear items has been incremented.'))
.catch((error) => {
logger.error('Error occurred while updating gear');
throw new Error(error);
});
} else {
await gearsRef
.add({
items: increment(1),
maker: exif.image?.Make || null,
model: gearName,
name: gearName,
type: 'photo',
createdAt: serverTimestamp(),
updatedAt: serverTimestamp(),
})
.then((ref) => {
logger.info('New gear has been added.');
gearId = ref.id;
})
.catch((error) => {
logger.error('Error occurred while creating gear');
throw new Error(error);
});
}

// Update item
const mediaPath = path.dirname(name);
const itemPath = mediaPath.replace('media/', 'items/');
const itemRef = firestore().doc(itemPath);
return itemRef
.set(
{
date: getZonedTime(exif?.exif?.DateTimeOriginal),
gearId,
medium: {
exif: {
...exif,
exif: {
...exif?.exif,
DateTimeDigitized: getZonedTime(exif?.exif?.DateTimeDigitized),
DateTimeOriginal: getZonedTime(exif?.exif?.DateTimeOriginal),
},
image: {
...exif?.image,
ModifyDate: getZonedTime(exif?.image?.ModifyDate),
},
thumbnail: {
...exif?.thumbnail,
ModifyDate: getZonedTime(exif?.thumbnail?.ModifyDate),
},
},
thumbnail: `${mediaPath}/${destinationFileName}`,
},
updatedAt: serverTimestamp(),
},
{ merge: true }
)
.then(() => logger.info('Item information updated.'))
.catch((error) => {
logger.error('Error occurred while updating thumbnail path');
throw new Error(error);
});
});
3 changes: 3 additions & 0 deletions functions/tsconfig.dev.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
{
"include": [".eslintrc.json"]
}
14 changes: 14 additions & 0 deletions functions/tsconfig.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
{
"compilerOptions": {
"esModuleInterop": true,
"module": "commonjs",
"noImplicitReturns": true,
"noUnusedLocals": true,
"outDir": "lib",
"sourceMap": true,
"strict": true,
"target": "es2017"
},
"compileOnSave": true,
"include": ["src"]
}
7 changes: 6 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@
"author": "Kazuya Hara <iam@kazuyahara.dev>",
"workspaces": {
"packages": [
"functions",
"web"
]
},
Expand All @@ -19,8 +20,10 @@
"deploy:prd": "yarn build:prd && firebase use production && firebase deploy && firebase use default",
"deploy:stg": "yarn build:stg && firebase use staging && firebase deploy && firebase use default",
"eslint": "yarn workspaces foreach -p run eslint",
"functions": "yarn workspace functions",
"prepare": "husky install",
"prettier": "prettier -w .github/**/*.yml package.json README.md && yarn prettier:web",
"prettier": "prettier -w .github/**/*.yml package.json README.md && yarn prettier:functions && yarn prettier:web",
"prettier:functions": "prettier -w functions/*.json functions/src/**/*.ts",
"prettier:web": "prettier -w web/*.json web/src/**/*.{ts,tsx}",
"test": "yarn workspaces foreach -p run test",
"web": "yarn workspace web"
Expand Down Expand Up @@ -52,6 +55,7 @@
{
"files": [
"package.json",
"functions/package.json",
"web/package.json"
],
"from": "\"version\": \".*\"",
Expand All @@ -66,6 +70,7 @@
"assets": [
"CHANGELOG.md",
"package.json",
"functions/package.json",
"web/package.json"
],
"message": "chore(release): ${nextRelease.gitTag} [skip ci]"
Expand Down
13 changes: 13 additions & 0 deletions storage.rules
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
rules_version = '2';

service firebase.storage {
match /b/{bucket}/o {
function authenticated() {
return request.auth != null;
}

match /media/{mediumId}/{fileName} {
allow read, write: if authenticated();
}
}
}
22 changes: 22 additions & 0 deletions web/src/adapters/infrastructure/firebase.ts
Original file line number Diff line number Diff line change
@@ -1,12 +1,14 @@
import { FirebaseApp, initializeApp } from 'firebase/app';
import { Auth, getAuth } from 'firebase/auth';
import { Firestore, FirestoreError, getFirestore } from 'firebase/firestore';
import { FirebaseStorage, getStorage, StorageError } from 'firebase/storage';

export default class Firebase {
private _app: FirebaseApp;
private _auth: Auth;
private _firetore: Firestore;
private static _instance: Firebase; // eslint-disable-line no-use-before-define
private _storage: FirebaseStorage;

private constructor() {
this._app = initializeApp({
Expand All @@ -22,6 +24,7 @@ export default class Firebase {
this._auth = getAuth(this._app);
this._auth.languageCode = 'ja';
this._firetore = getFirestore(this._app);
this._storage = getStorage(this._app);
}

public static get instance(): Firebase {
Expand All @@ -44,6 +47,12 @@ export default class Firebase {
this._firetore = getFirestore(this._app);
return this._firetore;
}

public get storage(): FirebaseStorage {
if (this._storage) return this._storage;
this._storage = getStorage(this._app);
return this._storage;
}
}

export const handleFirestoreError = (error: FirestoreError): Error => {
Expand All @@ -58,3 +67,16 @@ export const handleFirestoreError = (error: FirestoreError): Error => {
return new Error('エラーが発生しました');
}
};

export const handleStorageError = (error: StorageError) => {
switch (error.code) {
case 'storage/retry-limit-exceeded':
return new Error('最大時間制限を超えました');
case 'storage/unauthenticated':
return new Error('ユーザー認証が必要です');
case 'storage/unauthorized':
return new Error('権限が不足しています');
default:
return new Error('エラーが発生しました');
}
};
Loading

0 comments on commit d2d6703

Please sign in to comment.