Netlify auth (#194)
This commit is contained in:
parent
578ffc91df
commit
1efc59a9fb
@ -104,6 +104,7 @@
|
||||
"markup-it": "git+https://github.com/cassiozen/markup-it.git",
|
||||
"material-design-icons": "^3.0.1",
|
||||
"moment": "^2.11.2",
|
||||
"netlify-auth-js": "^0.5.2",
|
||||
"normalize.css": "^4.2.0",
|
||||
"pluralize": "^3.0.0",
|
||||
"prismjs": "^1.5.1",
|
||||
|
@ -1,34 +1,34 @@
|
||||
import yaml from 'js-yaml';
|
||||
import { set, defaultsDeep } from 'lodash';
|
||||
import { currentBackend } from '../backends/backend';
|
||||
import { authenticate } from '../actions/auth';
|
||||
import * as MediaProxy from '../valueObjects/MediaProxy';
|
||||
import * as publishModes from '../constants/publishModes';
|
||||
import yaml from "js-yaml";
|
||||
import { set, defaultsDeep } from "lodash";
|
||||
import { currentBackend } from "../backends/backend";
|
||||
import { authenticate } from "../actions/auth";
|
||||
import * as MediaProxy from "../valueObjects/MediaProxy";
|
||||
import * as publishModes from "../constants/publishModes";
|
||||
|
||||
export const CONFIG_REQUEST = 'CONFIG_REQUEST';
|
||||
export const CONFIG_SUCCESS = 'CONFIG_SUCCESS';
|
||||
export const CONFIG_FAILURE = 'CONFIG_FAILURE';
|
||||
export const CONFIG_REQUEST = "CONFIG_REQUEST";
|
||||
export const CONFIG_SUCCESS = "CONFIG_SUCCESS";
|
||||
export const CONFIG_FAILURE = "CONFIG_FAILURE";
|
||||
|
||||
const defaults = {
|
||||
publish_mode: publishModes.SIMPLE,
|
||||
};
|
||||
|
||||
export function applyDefaults(config) {
|
||||
if (!('media_folder' in config)) {
|
||||
throw new Error('Error in configuration file: A `media_folder` wasn\'t found. Check your config.yml file.');
|
||||
if (!("media_folder" in config)) {
|
||||
throw new Error("Error in configuration file: A `media_folder` wasn't found. Check your config.yml file.");
|
||||
}
|
||||
|
||||
// Make sure there is a public folder
|
||||
set(defaults,
|
||||
'public_folder',
|
||||
config.media_folder.charAt(0) === '/' ? config.media_folder : `/${ config.media_folder }`);
|
||||
"public_folder",
|
||||
config.media_folder.charAt(0) === "/" ? config.media_folder : `/${ config.media_folder }`);
|
||||
|
||||
return defaultsDeep(config, defaults);
|
||||
}
|
||||
|
||||
function parseConfig(data) {
|
||||
const config = yaml.safeLoad(data);
|
||||
if (typeof CMS_ENV === 'string' && config[CMS_ENV]) {
|
||||
if (typeof CMS_ENV === "string" && config[CMS_ENV]) {
|
||||
// TODO: Add tests and refactor
|
||||
for (const key in config[CMS_ENV]) { // eslint-disable-line no-restricted-syntax
|
||||
if (config[CMS_ENV].hasOwnProperty(key)) { // eslint-disable-line no-prototype-builtins
|
||||
@ -57,7 +57,7 @@ export function configLoading() {
|
||||
export function configFailed(err) {
|
||||
return {
|
||||
type: CONFIG_FAILURE,
|
||||
error: 'Error loading config',
|
||||
error: "Error loading config",
|
||||
payload: err,
|
||||
};
|
||||
}
|
||||
@ -76,7 +76,7 @@ export function loadConfig() {
|
||||
return (dispatch) => {
|
||||
dispatch(configLoading());
|
||||
|
||||
fetch('config.yml').then((response) => {
|
||||
fetch("config.yml").then((response) => {
|
||||
if (response.status !== 200) {
|
||||
throw new Error(`Failed to load config.yml (${ response.status })`);
|
||||
}
|
||||
@ -88,7 +88,9 @@ export function loadConfig() {
|
||||
.then((config) => {
|
||||
dispatch(configDidLoad(config));
|
||||
const backend = currentBackend(config);
|
||||
const user = backend && backend.currentUser();
|
||||
return backend && backend.currentUser();
|
||||
})
|
||||
.then((user) => {
|
||||
if (user) dispatch(authenticate(user));
|
||||
});
|
||||
}).catch((err) => {
|
||||
|
@ -1,12 +1,13 @@
|
||||
import TestRepoBackend from './test-repo/implementation';
|
||||
import GitHubBackend from './github/implementation';
|
||||
import NetlifyGitBackend from './netlify-git/implementation';
|
||||
import { resolveFormat } from '../formats/formats';
|
||||
import { selectListMethod, selectEntrySlug, selectEntryPath, selectAllowNewEntries } from '../reducers/collections';
|
||||
import { createEntry } from '../valueObjects/Entry';
|
||||
import TestRepoBackend from "./test-repo/implementation";
|
||||
import GitHubBackend from "./github/implementation";
|
||||
import NetlifyGitBackend from "./netlify-git/implementation";
|
||||
import NetlifyAuthBackend from "./netlify-auth/implementation";
|
||||
import { resolveFormat } from "../formats/formats";
|
||||
import { selectListMethod, selectEntrySlug, selectEntryPath, selectAllowNewEntries } from "../reducers/collections";
|
||||
import { createEntry } from "../valueObjects/Entry";
|
||||
|
||||
class LocalStorageAuthStore {
|
||||
storageKey = 'nf-cms-user';
|
||||
storageKey = "nf-cms-user";
|
||||
|
||||
retrieve() {
|
||||
const data = window.localStorage.getItem(this.storageKey);
|
||||
@ -22,21 +23,21 @@ class LocalStorageAuthStore {
|
||||
}
|
||||
}
|
||||
|
||||
const slugFormatter = (template = '{{slug}}', entryData) => {
|
||||
const slugFormatter = (template = "{{slug}}", entryData) => {
|
||||
const date = new Date();
|
||||
const identifier = entryData.get('title', entryData.get('path'));
|
||||
const identifier = entryData.get("title", entryData.get("path"));
|
||||
return template.replace(/\{\{([^\}]+)\}\}/g, (_, field) => {
|
||||
switch (field) {
|
||||
case 'year':
|
||||
case "year":
|
||||
return date.getFullYear();
|
||||
case 'month':
|
||||
case "month":
|
||||
return (`0${ date.getMonth() + 1 }`).slice(-2);
|
||||
case 'day':
|
||||
case "day":
|
||||
return (`0${ date.getDate() }`).slice(-2);
|
||||
case 'slug':
|
||||
return identifier.trim().toLowerCase().replace(/[^a-z0-9\.\-_]+/gi, '-');
|
||||
case "slug":
|
||||
return identifier.trim().toLowerCase().replace(/[^a-z0-9\.\-_]+/gi, "-");
|
||||
default:
|
||||
return entryData.get(field, '').trim().toLowerCase().replace(/[^a-z0-9\.\-_]+/gi, '-');
|
||||
return entryData.get(field, "").trim().toLowerCase().replace(/[^a-z0-9\.\-_]+/gi, "-");
|
||||
}
|
||||
});
|
||||
};
|
||||
@ -46,7 +47,7 @@ class Backend {
|
||||
this.implementation = implementation;
|
||||
this.authStore = authStore;
|
||||
if (this.implementation === null) {
|
||||
throw new Error('Cannot instantiate a Backend with no implementation');
|
||||
throw new Error("Cannot instantiate a Backend with no implementation");
|
||||
}
|
||||
}
|
||||
|
||||
@ -54,10 +55,9 @@ class Backend {
|
||||
if (this.user) { return this.user; }
|
||||
const stored = this.authStore && this.authStore.retrieve();
|
||||
if (stored) {
|
||||
this.implementation.setUser(stored);
|
||||
return stored;
|
||||
return Promise.resolve(this.implementation.setUser(stored)).then(() => stored);
|
||||
}
|
||||
return null;
|
||||
return Promise.resolve(null);
|
||||
}
|
||||
|
||||
authComponent() {
|
||||
@ -75,7 +75,7 @@ class Backend {
|
||||
if (this.authStore) {
|
||||
this.authStore.logout();
|
||||
} else {
|
||||
throw new Error('User isn\'t authenticated.');
|
||||
throw new Error("User isn't authenticated.");
|
||||
}
|
||||
}
|
||||
|
||||
@ -84,7 +84,7 @@ class Backend {
|
||||
return listMethod.call(this.implementation, collection)
|
||||
.then(loadedEntries => (
|
||||
loadedEntries.map(loadedEntry => createEntry(
|
||||
collection.get('name'),
|
||||
collection.get("name"),
|
||||
selectEntrySlug(collection, loadedEntry.file.path),
|
||||
loadedEntry.file.path,
|
||||
{ raw: loadedEntry.data, label: loadedEntry.file.label }
|
||||
@ -100,7 +100,7 @@ class Backend {
|
||||
getEntry(collection, slug) {
|
||||
return this.implementation.getEntry(collection, slug, selectEntryPath(collection, slug))
|
||||
.then(loadedEntry => this.entryWithFormat(collection, slug)(createEntry(
|
||||
collection.get('name'),
|
||||
collection.get("name"),
|
||||
slug,
|
||||
loadedEntry.file.path,
|
||||
{ raw: loadedEntry.data, label: loadedEntry.file.label }
|
||||
@ -123,21 +123,21 @@ class Backend {
|
||||
.then(loadedEntries => loadedEntries.filter(entry => entry !== null))
|
||||
.then(entries => (
|
||||
entries.map((loadedEntry) => {
|
||||
const entry = createEntry('draft', loadedEntry.slug, loadedEntry.file.path, { raw: loadedEntry.data });
|
||||
const entry = createEntry("draft", loadedEntry.slug, loadedEntry.file.path, { raw: loadedEntry.data });
|
||||
entry.metaData = loadedEntry.metaData;
|
||||
return entry;
|
||||
})
|
||||
))
|
||||
.then(entries => ({
|
||||
pagination: 0,
|
||||
entries: entries.map(this.entryWithFormat('editorialWorkflow')),
|
||||
entries: entries.map(this.entryWithFormat("editorialWorkflow")),
|
||||
}));
|
||||
}
|
||||
|
||||
unpublishedEntry(collection, slug) {
|
||||
return this.implementation.unpublishedEntry(collection, slug)
|
||||
.then((loadedEntry) => {
|
||||
const entry = createEntry('draft', loadedEntry.slug, loadedEntry.file.path, { raw: loadedEntry.data });
|
||||
const entry = createEntry("draft", loadedEntry.slug, loadedEntry.file.path, { raw: loadedEntry.data });
|
||||
entry.metaData = loadedEntry.metaData;
|
||||
return entry;
|
||||
})
|
||||
@ -145,20 +145,20 @@ class Backend {
|
||||
}
|
||||
|
||||
persistEntry(config, collection, entryDraft, MediaFiles, options) {
|
||||
const newEntry = entryDraft.getIn(['entry', 'newRecord']) || false;
|
||||
const newEntry = entryDraft.getIn(["entry", "newRecord"]) || false;
|
||||
|
||||
const parsedData = {
|
||||
title: entryDraft.getIn(['entry', 'data', 'title'], 'No Title'),
|
||||
description: entryDraft.getIn(['entry', 'data', 'description'], 'No Description!'),
|
||||
title: entryDraft.getIn(["entry", "data", "title"], "No Title"),
|
||||
description: entryDraft.getIn(["entry", "data", "description"], "No Description!"),
|
||||
};
|
||||
|
||||
const entryData = entryDraft.getIn(['entry', 'data']).toJS();
|
||||
const entryData = entryDraft.getIn(["entry", "data"]).toJS();
|
||||
let entryObj;
|
||||
if (newEntry) {
|
||||
if (!selectAllowNewEntries(collection)) {
|
||||
throw (new Error('Not allowed to create new entries in this collection'));
|
||||
throw (new Error("Not allowed to create new entries in this collection"));
|
||||
}
|
||||
const slug = slugFormatter(collection.get('slug'), entryDraft.getIn(['entry', 'data']));
|
||||
const slug = slugFormatter(collection.get("slug"), entryDraft.getIn(["entry", "data"]));
|
||||
const path = selectEntryPath(collection, slug);
|
||||
entryObj = {
|
||||
path,
|
||||
@ -166,20 +166,20 @@ class Backend {
|
||||
raw: this.entryToRaw(collection, entryData),
|
||||
};
|
||||
} else {
|
||||
const path = entryDraft.getIn(['entry', 'path']);
|
||||
const path = entryDraft.getIn(["entry", "path"]);
|
||||
entryObj = {
|
||||
path,
|
||||
slug: entryDraft.getIn(['entry', 'slug']),
|
||||
slug: entryDraft.getIn(["entry", "slug"]),
|
||||
raw: this.entryToRaw(collection, entryData),
|
||||
};
|
||||
}
|
||||
|
||||
const commitMessage = `${ (newEntry ? 'Created ' : 'Updated ') +
|
||||
collection.get('label') } “${ entryObj.slug }”`;
|
||||
const commitMessage = `${ (newEntry ? "Created " : "Updated ") +
|
||||
collection.get("label") } “${ entryObj.slug }”`;
|
||||
|
||||
const mode = config.get('publish_mode');
|
||||
const mode = config.get("publish_mode");
|
||||
|
||||
const collectionName = collection.get('name');
|
||||
const collectionName = collection.get("name");
|
||||
|
||||
return this.implementation.persistEntry(entryObj, MediaFiles, {
|
||||
newEntry, parsedData, commitMessage, collectionName, mode, ...options,
|
||||
@ -206,20 +206,22 @@ class Backend {
|
||||
}
|
||||
|
||||
export function resolveBackend(config) {
|
||||
const name = config.getIn(['backend', 'name']);
|
||||
const name = config.getIn(["backend", "name"]);
|
||||
if (name == null) {
|
||||
throw new Error('No backend defined in configuration');
|
||||
throw new Error("No backend defined in configuration");
|
||||
}
|
||||
|
||||
const authStore = new LocalStorageAuthStore();
|
||||
|
||||
switch (name) {
|
||||
case 'test-repo':
|
||||
case "test-repo":
|
||||
return new Backend(new TestRepoBackend(config), authStore);
|
||||
case 'github':
|
||||
case "github":
|
||||
return new Backend(new GitHubBackend(config), authStore);
|
||||
case 'netlify-git':
|
||||
case "netlify-git":
|
||||
return new Backend(new NetlifyGitBackend(config), authStore);
|
||||
case "netlify-auth":
|
||||
return new Backend(new NetlifyAuthBackend(config), authStore);
|
||||
default:
|
||||
throw new Error(`Backend not found: ${ name }`);
|
||||
}
|
||||
@ -230,7 +232,7 @@ export const currentBackend = (function () {
|
||||
|
||||
return (config) => {
|
||||
if (backend) { return backend; }
|
||||
if (config.get('backend')) {
|
||||
if (config.get("backend")) {
|
||||
return backend = resolveBackend(config);
|
||||
}
|
||||
};
|
||||
|
@ -1,29 +1,34 @@
|
||||
import LocalForage from 'localforage';
|
||||
import MediaProxy from '../../valueObjects/MediaProxy';
|
||||
import { Base64 } from 'js-base64';
|
||||
import _ from 'lodash';
|
||||
import { SIMPLE, EDITORIAL_WORKFLOW, status } from '../../constants/publishModes';
|
||||
|
||||
const API_ROOT = 'https://api.github.com';
|
||||
import LocalForage from "localforage";
|
||||
import { Base64 } from "js-base64";
|
||||
import _ from "lodash";
|
||||
import MediaProxy from "../../valueObjects/MediaProxy";
|
||||
import { SIMPLE, EDITORIAL_WORKFLOW, status } from "../../constants/publishModes";
|
||||
|
||||
export default class API {
|
||||
constructor(token, repo, branch) {
|
||||
this.token = token;
|
||||
this.repo = repo;
|
||||
this.branch = branch;
|
||||
constructor(config) {
|
||||
this.api_root = config.api_root || "https://api.github.com";
|
||||
this.token = config.token || false;
|
||||
this.branch = config.branch || "master";
|
||||
this.repo = config.repo || "";
|
||||
this.repoURL = `/repos/${ this.repo }`;
|
||||
}
|
||||
|
||||
user() {
|
||||
return this.request('/user');
|
||||
return this.request("/user");
|
||||
}
|
||||
|
||||
requestHeaders(headers = {}) {
|
||||
return {
|
||||
Authorization: `token ${ this.token }`,
|
||||
'Content-Type': 'application/json',
|
||||
const baseHeader = {
|
||||
"Content-Type": "application/json",
|
||||
...headers,
|
||||
};
|
||||
|
||||
if (this.token) {
|
||||
baseHeader.Authorization = `token ${ this.token }`;
|
||||
return baseHeader;
|
||||
}
|
||||
|
||||
return baseHeader;
|
||||
}
|
||||
|
||||
parseJsonResponse(response) {
|
||||
@ -44,16 +49,16 @@ export default class API {
|
||||
}
|
||||
}
|
||||
if (params.length) {
|
||||
path += `?${ params.join('&') }`;
|
||||
path += `?${ params.join("&") }`;
|
||||
}
|
||||
return API_ROOT + path;
|
||||
return this.api_root + path;
|
||||
}
|
||||
|
||||
request(path, options = {}) {
|
||||
const headers = this.requestHeaders(options.headers || {});
|
||||
const url = this.urlFor(path, options);
|
||||
return fetch(url, { ...options, headers }).then((response) => {
|
||||
const contentType = response.headers.get('Content-Type');
|
||||
const contentType = response.headers.get("Content-Type");
|
||||
if (contentType && contentType.match(/json/)) {
|
||||
return this.parseJsonResponse(response);
|
||||
}
|
||||
@ -64,27 +69,28 @@ export default class API {
|
||||
|
||||
checkMetadataRef() {
|
||||
return this.request(`${ this.repoURL }/git/refs/meta/_netlify_cms?${ Date.now() }`, {
|
||||
cache: 'no-store',
|
||||
cache: "no-store",
|
||||
})
|
||||
.then(response => response.object)
|
||||
.catch((error) => {
|
||||
// Meta ref doesn't exist
|
||||
const readme = {
|
||||
raw: '# Netlify CMS\n\nThis tree is used by the Netlify CMS to store metadata information for specific files and branches.',
|
||||
raw: "# Netlify CMS\n\nThis tree is used by the Netlify CMS to store metadata information for specific files and branches.",
|
||||
};
|
||||
|
||||
return this.uploadBlob(readme)
|
||||
.then(item => this.request(`${ this.repoURL }/git/trees`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ tree: [{ path: 'README.md', mode: '100644', type: 'blob', sha: item.sha }] }),
|
||||
method: "POST",
|
||||
body: JSON.stringify({ tree: [{ path: "README.md", mode: "100644", type: "blob", sha: item.sha }] }),
|
||||
}))
|
||||
.then(tree => this.commit('First Commit', tree))
|
||||
.then(response => this.createRef('meta', '_netlify_cms', response.sha))
|
||||
.then(tree => this.commit("First Commit", tree))
|
||||
.then(response => this.createRef("meta", "_netlify_cms", response.sha))
|
||||
.then(response => response.object);
|
||||
});
|
||||
}
|
||||
|
||||
storeMetadata(key, data) {
|
||||
console.log('Trying to store Metadata');
|
||||
return this.checkMetadataRef()
|
||||
.then((branchData) => {
|
||||
const fileTree = {
|
||||
@ -96,9 +102,9 @@ export default class API {
|
||||
};
|
||||
|
||||
return this.uploadBlob(fileTree[`${ key }.json`])
|
||||
.then(item => this.updateTree(branchData.sha, '/', fileTree))
|
||||
.then(item => this.updateTree(branchData.sha, "/", fileTree))
|
||||
.then(changeTree => this.commit(`Updating “${ key }” metadata`, changeTree))
|
||||
.then(response => this.patchRef('meta', '_netlify_cms', response.sha))
|
||||
.then(response => this.patchRef("meta", "_netlify_cms", response.sha))
|
||||
.then(() => {
|
||||
LocalForage.setItem(`gh.meta.${ key }`, {
|
||||
expires: Date.now() + 300000, // In 5 minutes
|
||||
@ -114,9 +120,9 @@ export default class API {
|
||||
if (cached && cached.expires > Date.now()) { return cached.data; }
|
||||
console.log("%c Checking for MetaData files", "line-height: 30px;text-align: center;font-weight: bold"); // eslint-disable-line
|
||||
return this.request(`${ this.repoURL }/contents/${ key }.json`, {
|
||||
params: { ref: 'refs/meta/_netlify_cms' },
|
||||
headers: { Accept: 'application/vnd.github.VERSION.raw' },
|
||||
cache: 'no-store',
|
||||
params: { ref: "refs/meta/_netlify_cms" },
|
||||
headers: { Accept: "application/vnd.github.VERSION.raw" },
|
||||
cache: "no-store",
|
||||
})
|
||||
.then(response => JSON.parse(response))
|
||||
.catch(error => console.log("%c %s does not have metadata", "line-height: 30px;text-align: center;font-weight: bold", key)); // eslint-disable-line
|
||||
@ -129,7 +135,7 @@ export default class API {
|
||||
if (cached) { return cached; }
|
||||
|
||||
return this.request(`${ this.repoURL }/contents/${ path }`, {
|
||||
headers: { Accept: 'application/vnd.github.VERSION.raw' },
|
||||
headers: { Accept: "application/vnd.github.VERSION.raw" },
|
||||
params: { ref: branch },
|
||||
cache: false,
|
||||
}).then((result) => {
|
||||
@ -155,9 +161,7 @@ export default class API {
|
||||
return this.readFile(data.objects.entry, null, data.branch);
|
||||
})
|
||||
.then(fileData => ({ metaData, fileData }))
|
||||
.catch((error) => {
|
||||
return null;
|
||||
});
|
||||
.catch(error => null);
|
||||
return unpublishedPromise;
|
||||
}
|
||||
|
||||
@ -178,7 +182,7 @@ export default class API {
|
||||
files.forEach((file) => {
|
||||
if (file.uploaded) { return; }
|
||||
uploadPromises.push(this.uploadBlob(file));
|
||||
parts = file.path.split('/').filter(part => part);
|
||||
parts = file.path.split("/").filter(part => part);
|
||||
filename = parts.pop();
|
||||
subtree = fileTree;
|
||||
while (part = parts.shift()) {
|
||||
@ -191,7 +195,7 @@ export default class API {
|
||||
return Promise.all(uploadPromises).then(() => {
|
||||
if (!options.mode || (options.mode && options.mode === SIMPLE)) {
|
||||
return this.getBranch()
|
||||
.then(branchData => this.updateTree(branchData.commit.sha, '/', fileTree))
|
||||
.then(branchData => this.updateTree(branchData.commit.sha, "/", fileTree))
|
||||
.then(changeTree => this.commit(options.commitMessage, changeTree))
|
||||
.then(response => this.patchBranch(this.branch, response.sha));
|
||||
} else if (options.mode && options.mode === EDITORIAL_WORKFLOW) {
|
||||
@ -212,16 +216,13 @@ export default class API {
|
||||
const branchName = `cms/${ contentKey }`;
|
||||
|
||||
return this.getBranch()
|
||||
.then(branchData => this.updateTree(branchData.commit.sha, '/', fileTree))
|
||||
.then(branchData => this.updateTree(branchData.commit.sha, "/", fileTree))
|
||||
.then(changeTree => this.commit(options.commitMessage, changeTree))
|
||||
.then(commitResponse => this.createBranch(branchName, commitResponse.sha))
|
||||
.then(branchResponse => this.createPR(options.commitMessage, branchName))
|
||||
.then((prResponse) => {
|
||||
return this.user().then((user) => {
|
||||
return user.name ? user.name : user.login;
|
||||
})
|
||||
.then(prResponse => this.user().then(user => user.name ? user.name : user.login)
|
||||
.then(username => this.storeMetadata(contentKey, {
|
||||
type: 'PR',
|
||||
type: "PR",
|
||||
pr: {
|
||||
number: prResponse.number,
|
||||
head: prResponse.head && prResponse.head.sha,
|
||||
@ -237,19 +238,16 @@ export default class API {
|
||||
files: filesList,
|
||||
},
|
||||
timeStamp: new Date().toISOString(),
|
||||
}));
|
||||
});
|
||||
})));
|
||||
} else {
|
||||
// Entry is already on editorial review workflow - just update metadata and commit to existing branch
|
||||
return this.getBranch(branchName)
|
||||
.then(branchData => this.updateTree(branchData.commit.sha, '/', fileTree))
|
||||
.then(branchData => this.updateTree(branchData.commit.sha, "/", fileTree))
|
||||
.then(changeTree => this.commit(options.commitMessage, changeTree))
|
||||
.then((response) => {
|
||||
const contentKey = entry.slug;
|
||||
const branchName = `cms/${ contentKey }`;
|
||||
return this.user().then((user) => {
|
||||
return user.name ? user.name : user.login;
|
||||
})
|
||||
return this.user().then(user => user.name ? user.name : user.login)
|
||||
.then(username => this.retrieveMetadata(contentKey))
|
||||
.then((metadata) => {
|
||||
let files = metadata.objects && metadata.objects.files || [];
|
||||
@ -275,12 +273,10 @@ export default class API {
|
||||
updateUnpublishedEntryStatus(collection, slug, status) {
|
||||
const contentKey = slug;
|
||||
return this.retrieveMetadata(contentKey)
|
||||
.then((metadata) => {
|
||||
return {
|
||||
...metadata,
|
||||
status,
|
||||
};
|
||||
})
|
||||
.then(metadata => ({
|
||||
...metadata,
|
||||
status,
|
||||
}))
|
||||
.then(updatedMetadata => this.storeMetadata(contentKey, updatedMetadata));
|
||||
}
|
||||
|
||||
@ -297,21 +293,21 @@ export default class API {
|
||||
|
||||
createRef(type, name, sha) {
|
||||
return this.request(`${ this.repoURL }/git/refs`, {
|
||||
method: 'POST',
|
||||
method: "POST",
|
||||
body: JSON.stringify({ ref: `refs/${ type }/${ name }`, sha }),
|
||||
});
|
||||
}
|
||||
|
||||
patchRef(type, name, sha) {
|
||||
return this.request(`${ this.repoURL }/git/refs/${ type }/${ name }`, {
|
||||
method: 'PATCH',
|
||||
method: "PATCH",
|
||||
body: JSON.stringify({ sha }),
|
||||
});
|
||||
}
|
||||
|
||||
deleteRef(type, name, sha) {
|
||||
return this.request(`${ this.repoURL }/git/refs/${ type }/${ name }`, {
|
||||
method: 'DELETE',
|
||||
method: "DELETE",
|
||||
});
|
||||
}
|
||||
|
||||
@ -320,30 +316,30 @@ export default class API {
|
||||
}
|
||||
|
||||
createBranch(branchName, sha) {
|
||||
return this.createRef('heads', branchName, sha);
|
||||
return this.createRef("heads", branchName, sha);
|
||||
}
|
||||
|
||||
patchBranch(branchName, sha) {
|
||||
return this.patchRef('heads', branchName, sha);
|
||||
return this.patchRef("heads", branchName, sha);
|
||||
}
|
||||
|
||||
deleteBranch(branchName) {
|
||||
return this.deleteRef('heads', branchName);
|
||||
return this.deleteRef("heads", branchName);
|
||||
}
|
||||
|
||||
createPR(title, head, base = 'master') {
|
||||
const body = 'Automatically generated by Netlify CMS';
|
||||
createPR(title, head, base = "master") {
|
||||
const body = "Automatically generated by Netlify CMS";
|
||||
return this.request(`${ this.repoURL }/pulls`, {
|
||||
method: 'POST',
|
||||
method: "POST",
|
||||
body: JSON.stringify({ title, body, head, base }),
|
||||
});
|
||||
}
|
||||
|
||||
mergePR(headSha, number) {
|
||||
return this.request(`${ this.repoURL }/pulls/${ number }/merge`, {
|
||||
method: 'PUT',
|
||||
method: "PUT",
|
||||
body: JSON.stringify({
|
||||
commit_message: 'Automatically generated. Merged on Netlify CMS.',
|
||||
commit_message: "Automatically generated. Merged on Netlify CMS.",
|
||||
sha: headSha,
|
||||
}),
|
||||
});
|
||||
@ -362,19 +358,17 @@ export default class API {
|
||||
uploadBlob(item) {
|
||||
const content = item instanceof MediaProxy ? item.toBase64() : this.toBase64(item.raw);
|
||||
|
||||
return content.then((contentBase64) => {
|
||||
return this.request(`${ this.repoURL }/git/blobs`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({
|
||||
content: contentBase64,
|
||||
encoding: 'base64',
|
||||
}),
|
||||
}).then((response) => {
|
||||
item.sha = response.sha;
|
||||
item.uploaded = true;
|
||||
return item;
|
||||
});
|
||||
});
|
||||
return content.then(contentBase64 => this.request(`${ this.repoURL }/git/blobs`, {
|
||||
method: "POST",
|
||||
body: JSON.stringify({
|
||||
content: contentBase64,
|
||||
encoding: "base64",
|
||||
}),
|
||||
}).then((response) => {
|
||||
item.sha = response.sha;
|
||||
item.uploaded = true;
|
||||
return item;
|
||||
}));
|
||||
}
|
||||
|
||||
updateTree(sha, path, fileTree) {
|
||||
@ -402,19 +396,15 @@ export default class API {
|
||||
if (added[filename]) { continue; }
|
||||
updates.push(
|
||||
fileOrDir.file ?
|
||||
{ path: filename, mode: '100644', type: 'blob', sha: fileOrDir.sha } :
|
||||
{ path: filename, mode: "100644", type: "blob", sha: fileOrDir.sha } :
|
||||
this.updateTree(null, filename, fileOrDir)
|
||||
);
|
||||
}
|
||||
return Promise.all(updates)
|
||||
.then((updates) => {
|
||||
return this.request(`${ this.repoURL }/git/trees`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ base_tree: sha, tree: updates }),
|
||||
});
|
||||
}).then((response) => {
|
||||
return { path, mode: '040000', type: 'tree', sha: response.sha, parentSha: sha };
|
||||
});
|
||||
.then(updates => this.request(`${ this.repoURL }/git/trees`, {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ base_tree: sha, tree: updates }),
|
||||
})).then(response => ({ path, mode: "040000", type: "tree", sha: response.sha, parentSha: sha }));
|
||||
});
|
||||
}
|
||||
|
||||
@ -422,7 +412,7 @@ export default class API {
|
||||
const tree = changeTree.sha;
|
||||
const parents = changeTree.parentSha ? [changeTree.parentSha] : [];
|
||||
return this.request(`${ this.repoURL }/git/commits`, {
|
||||
method: 'POST',
|
||||
method: "POST",
|
||||
body: JSON.stringify({ message, tree, parents }),
|
||||
});
|
||||
}
|
||||
|
@ -2,7 +2,7 @@
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
height: 100%;
|
||||
height: 100vh;
|
||||
}
|
||||
|
||||
.button {
|
||||
|
@ -1,17 +1,19 @@
|
||||
import semaphore from 'semaphore';
|
||||
import AuthenticationPage from './AuthenticationPage';
|
||||
import API from './API';
|
||||
import semaphore from "semaphore";
|
||||
import AuthenticationPage from "./AuthenticationPage";
|
||||
import API from "./API";
|
||||
|
||||
const MAX_CONCURRENT_DOWNLOADS = 10;
|
||||
|
||||
export default class GitHub {
|
||||
constructor(config) {
|
||||
constructor(config, proxied = false) {
|
||||
this.config = config;
|
||||
if (config.getIn(['backend', 'repo']) == null) {
|
||||
throw new Error('The GitHub backend needs a "repo" in the backend configuration.');
|
||||
|
||||
if (!proxied && config.getIn(["backend", "repo"]) == null) {
|
||||
throw new Error("The GitHub backend needs a \"repo\" in the backend configuration.");
|
||||
}
|
||||
this.repo = config.getIn(['backend', 'repo']);
|
||||
this.branch = config.getIn(['backend', 'branch']) || 'master';
|
||||
|
||||
this.repo = config.getIn(["backend", "repo"], "");
|
||||
this.branch = config.getIn(["backend", "branch"], "master");
|
||||
}
|
||||
|
||||
authComponent() {
|
||||
@ -19,11 +21,11 @@ export default class GitHub {
|
||||
}
|
||||
|
||||
setUser(user) {
|
||||
this.api = new API(user.token, this.repo, this.branch || 'master');
|
||||
this.api = new API({ token: user.token, branch: this.branch, repo: this.repo });
|
||||
}
|
||||
|
||||
authenticate(state) {
|
||||
this.api = new API(state.token, this.repo, this.branch || 'master');
|
||||
this.api = new API({ token: state.token, branch: this.branch, repo: this.repo });
|
||||
return this.api.user().then((user) => {
|
||||
user.token = state.token;
|
||||
return user;
|
||||
@ -31,14 +33,14 @@ export default class GitHub {
|
||||
}
|
||||
|
||||
entriesByFolder(collection) {
|
||||
return this.api.listFiles(collection.get('folder'))
|
||||
return this.api.listFiles(collection.get("folder"))
|
||||
.then(this.fetchFiles);
|
||||
}
|
||||
|
||||
entriesByFiles(collection) {
|
||||
const files = collection.get('files').map(collectionFile => ({
|
||||
path: collectionFile.get('file'),
|
||||
label: collectionFile.get('label'),
|
||||
const files = collection.get("files").map(collectionFile => ({
|
||||
path: collectionFile.get("file"),
|
||||
label: collectionFile.get("label"),
|
||||
}));
|
||||
return this.fetchFiles(files);
|
||||
}
|
||||
@ -78,7 +80,7 @@ export default class GitHub {
|
||||
const promises = [];
|
||||
branches.map((branch) => {
|
||||
promises.push(new Promise((resolve, reject) => {
|
||||
const slug = branch.ref.split('refs/heads/cms/').pop();
|
||||
const slug = branch.ref.split("refs/heads/cms/").pop();
|
||||
return sem.take(() => this.api.readUnpublishedBranchFile(slug).then((data) => {
|
||||
if (data === null || data === undefined) {
|
||||
resolve(null);
|
||||
@ -102,7 +104,7 @@ export default class GitHub {
|
||||
return Promise.all(promises);
|
||||
})
|
||||
.catch((error) => {
|
||||
if (error.message === 'Not Found') {
|
||||
if (error.message === "Not Found") {
|
||||
return Promise.resolve([]);
|
||||
}
|
||||
return error;
|
||||
|
74
src/backends/netlify-auth/API.js
Normal file
74
src/backends/netlify-auth/API.js
Normal file
@ -0,0 +1,74 @@
|
||||
import GithubAPI from "../github/API";
|
||||
|
||||
export default class API extends GithubAPI {
|
||||
constructor(config) {
|
||||
super(config);
|
||||
this.api_root = config.api_root;
|
||||
this.jwtToken = config.jwtToken;
|
||||
this.commitAuthor = config.commitAuthor;
|
||||
this.repoURL = "";
|
||||
}
|
||||
|
||||
|
||||
requestHeaders(headers = {}) {
|
||||
const baseHeader = {
|
||||
Authorization: `Bearer ${ this.jwtToken }`,
|
||||
"Content-Type": "application/json",
|
||||
...headers,
|
||||
};
|
||||
|
||||
return baseHeader;
|
||||
}
|
||||
|
||||
|
||||
urlFor(path, options) {
|
||||
const params = [];
|
||||
if (options.params) {
|
||||
for (const key in options.params) {
|
||||
params.push(`${ key }=${ encodeURIComponent(options.params[key]) }`);
|
||||
}
|
||||
}
|
||||
if (params.length) {
|
||||
path += `?${ params.join("&") }`;
|
||||
}
|
||||
return this.api_root + path;
|
||||
}
|
||||
|
||||
user() {
|
||||
return Promise.resolve(this.commitAuthor);
|
||||
}
|
||||
|
||||
request(path, options = {}) {
|
||||
const headers = this.requestHeaders(options.headers || {});
|
||||
const url = this.urlFor(path, options);
|
||||
return fetch(url, { ...options, headers }).then((response) => {
|
||||
const contentType = response.headers.get("Content-Type");
|
||||
if (contentType && contentType.match(/json/)) {
|
||||
return this.parseJsonResponse(response);
|
||||
}
|
||||
|
||||
return response.text();
|
||||
});
|
||||
}
|
||||
|
||||
commit(message, changeTree) {
|
||||
const commitParams = {
|
||||
message,
|
||||
tree: changeTree.sha,
|
||||
parents: changeTree.parentSha ? [changeTree.parentSha] : [],
|
||||
};
|
||||
|
||||
if (this.commitAuthor) {
|
||||
commitParams.author = {
|
||||
...this.commitAuthor,
|
||||
date: new Date().toISOString(),
|
||||
};
|
||||
}
|
||||
|
||||
return this.request("/git/commits", {
|
||||
method: "POST",
|
||||
body: JSON.stringify(commitParams),
|
||||
});
|
||||
}
|
||||
|
||||
}
|
21
src/backends/netlify-auth/AuthenticationPage.css
Normal file
21
src/backends/netlify-auth/AuthenticationPage.css
Normal file
@ -0,0 +1,21 @@
|
||||
.root {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
height: 100vh;
|
||||
}
|
||||
|
||||
.card {
|
||||
width: 350px;
|
||||
padding: 10px;
|
||||
}
|
||||
|
||||
.card img {
|
||||
display: block;
|
||||
margin: auto;
|
||||
}
|
||||
|
||||
.button {
|
||||
padding: .25em 1em;
|
||||
height: auto;
|
||||
}
|
50
src/backends/netlify-auth/AuthenticationPage.js
Normal file
50
src/backends/netlify-auth/AuthenticationPage.js
Normal file
@ -0,0 +1,50 @@
|
||||
import React from "react";
|
||||
import Input from "react-toolbox/lib/input";
|
||||
import Button from "react-toolbox/lib/button";
|
||||
import { Card, Icon } from "../../components/UI";
|
||||
import logo from "./netlify_logo.svg";
|
||||
import styles from "./AuthenticationPage.css";
|
||||
|
||||
export default class AuthenticationPage extends React.Component {
|
||||
static propTypes = {
|
||||
onLogin: React.PropTypes.func.isRequired,
|
||||
};
|
||||
|
||||
state = { username: "", password: "" };
|
||||
|
||||
handleChange = (name, value) => {
|
||||
this.setState({ ...this.state, [name]: value });
|
||||
};
|
||||
|
||||
handleLogin = (e) => {
|
||||
e.preventDefault();
|
||||
AuthenticationPage.authClient.login(this.state.username, this.state.password, true)
|
||||
.then((user) => {
|
||||
this.props.onLogin(user);
|
||||
})
|
||||
.catch((err) => { throw err; });
|
||||
};
|
||||
|
||||
render() {
|
||||
const { loginError } = this.state;
|
||||
|
||||
return (
|
||||
<section className={styles.root}>
|
||||
{loginError && <p>{loginError}</p>}
|
||||
|
||||
<Card className={styles.card}>
|
||||
<img src={logo} width={100} />
|
||||
<Input type="text" label="Username" name="username" value={this.state.username} onChange={this.handleChange.bind(this, "username")} />
|
||||
<Input type="password" label="Password" name="password" value={this.state.password} onChange={this.handleChange.bind(this, "password")} />
|
||||
<Button
|
||||
className={styles.button}
|
||||
raised
|
||||
onClick={this.handleLogin}
|
||||
>
|
||||
<Icon type="login" /> Login
|
||||
</Button>
|
||||
</Card>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
}
|
48
src/backends/netlify-auth/implementation.js
Normal file
48
src/backends/netlify-auth/implementation.js
Normal file
@ -0,0 +1,48 @@
|
||||
import NetlifyAuthClient from "netlify-auth-js";
|
||||
import { omit } from "lodash";
|
||||
import GitHubBackend from "../github/implementation";
|
||||
import API from "./API";
|
||||
import AuthenticationPage from "./AuthenticationPage";
|
||||
|
||||
export default class NetlifyAuth extends GitHubBackend {
|
||||
constructor(config) {
|
||||
super(config, true);
|
||||
if (config.getIn(["backend", "auth_url"]) == null) { throw new Error("The NetlifyAuth backend needs an \"auth_url\" in the backend configuration."); }
|
||||
|
||||
if (config.getIn(["backend", "github_proxy_url"]) == null) {
|
||||
throw new Error("The NetlifyAuth backend needs an \"github_proxy_url\" in the backend configuration.");
|
||||
}
|
||||
|
||||
this.github_proxy_url = config.getIn(["backend", "github_proxy_url"]);
|
||||
|
||||
this.authClient = new NetlifyAuthClient({
|
||||
APIUrl: config.getIn(["backend", "auth_url"]),
|
||||
});
|
||||
|
||||
AuthenticationPage.authClient = this.authClient;
|
||||
}
|
||||
|
||||
setUser() {
|
||||
const user = this.authClient.currentUser();
|
||||
if (!user) return Promise.reject();
|
||||
|
||||
return this.authenticate(user);
|
||||
}
|
||||
|
||||
authenticate(user) {
|
||||
return user.jwt().then((token) => {
|
||||
const userData = {
|
||||
name: `${ user.user_metadata.firstname } ${ user.user_metadata.lastname }`,
|
||||
email: user.email,
|
||||
metadata: user.user_metadata,
|
||||
};
|
||||
this.api = new API({ api_root: this.github_proxy_url, jwtToken: token, commitAuthor: omit(userData, ["metadata"]) });
|
||||
return userData;
|
||||
});
|
||||
}
|
||||
|
||||
authComponent() {
|
||||
return AuthenticationPage;
|
||||
}
|
||||
|
||||
}
|
23
src/backends/netlify-auth/netlify_logo.svg
Normal file
23
src/backends/netlify-auth/netlify_logo.svg
Normal file
@ -0,0 +1,23 @@
|
||||
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
|
||||
<svg width="295px" height="284px" viewBox="0 0 295 284" version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink">
|
||||
<title>Netlify</title>
|
||||
<g transform="translate(149.500000, 142.500000) rotate(-315.000000) translate(-149.500000, -142.500000) translate(45.000000, 38.000000)">
|
||||
<g transform="translate(4.000000, 4.000000)" fill="#3FB5A0">
|
||||
<path d="M0,0 L200,0 L200,200 L0,200 L0,0 L0,0 Z" id="Shape"></path>
|
||||
</g>
|
||||
<g stroke="#FFFFFF" stroke-width="8">
|
||||
<path d="M209,70 L0,209 L209,70 Z" id="Shape"></path>
|
||||
<path d="M209,6 L0,93 L209,6 Z" id="Shape"></path>
|
||||
<path d="M209,180 L0,145 L209,180 Z" id="Shape"></path>
|
||||
<path d="M88,209 L43,0 L88,209 Z" id="Shape"></path>
|
||||
<path d="M209,172 L89,0 L209,172 Z" id="Shape"></path>
|
||||
<path d="M137,0 L57,209 L137,0 Z" id="Shape"></path>
|
||||
</g>
|
||||
<g transform="translate(43.000000, 33.000000)" fill="#FFFFFF">
|
||||
<circle id="Oval" cx="14" cy="38" r="14"></circle>
|
||||
<circle id="Oval" cx="77" cy="12" r="12"></circle>
|
||||
<circle id="Oval" cx="116" cy="70" r="12"></circle>
|
||||
<circle id="Oval" cx="35" cy="125" r="16"></circle>
|
||||
</g>
|
||||
</g>
|
||||
</svg>
|
After Width: | Height: | Size: 1.3 KiB |
@ -1,13 +1,14 @@
|
||||
import React, { PropTypes } from 'react';
|
||||
import ImmutablePropTypes from 'react-immutable-proptypes';
|
||||
import pluralize from 'pluralize';
|
||||
import { IndexLink } from 'react-router';
|
||||
import { IconMenu, Menu, MenuItem } from 'react-toolbox/lib/menu';
|
||||
import Avatar from 'react-toolbox/lib/avatar';
|
||||
import AppBar from 'react-toolbox/lib/app_bar';
|
||||
import FontIcon from 'react-toolbox/lib/font_icon';
|
||||
import FindBar from '../FindBar/FindBar';
|
||||
import styles from './AppHeader.css';
|
||||
import React, { PropTypes } from "react";
|
||||
import ImmutablePropTypes from "react-immutable-proptypes";
|
||||
import pluralize from "pluralize";
|
||||
import { IndexLink } from "react-router";
|
||||
import { IconMenu, Menu, MenuItem } from "react-toolbox/lib/menu";
|
||||
import Avatar from "react-toolbox/lib/avatar";
|
||||
import AppBar from "react-toolbox/lib/app_bar";
|
||||
import FontIcon from "react-toolbox/lib/font_icon";
|
||||
import FindBar from "../FindBar/FindBar";
|
||||
import { stringToRGB } from "../../lib/textHelper";
|
||||
import styles from "./AppHeader.css";
|
||||
|
||||
export default class AppHeader extends React.Component {
|
||||
|
||||
@ -68,6 +69,10 @@ export default class AppHeader extends React.Component {
|
||||
userMenuActive,
|
||||
} = this.state;
|
||||
|
||||
const avatarStyle = {
|
||||
backgroundColor: `#${ stringToRGB(user.get("name")) }`,
|
||||
};
|
||||
|
||||
return (
|
||||
<AppBar
|
||||
fixed
|
||||
@ -76,8 +81,9 @@ export default class AppHeader extends React.Component {
|
||||
rightIcon={
|
||||
<div>
|
||||
<Avatar
|
||||
title={user.get('name')}
|
||||
image={user.get('avatar_url')}
|
||||
style={avatarStyle}
|
||||
title={user.get("name")}
|
||||
image={user.get("avatar_url")}
|
||||
/>
|
||||
<Menu
|
||||
active={userMenuActive}
|
||||
@ -102,7 +108,6 @@ export default class AppHeader extends React.Component {
|
||||
/>
|
||||
<IconMenu
|
||||
theme={styles}
|
||||
active={createMenuActive}
|
||||
icon="create"
|
||||
onClick={this.handleCreateButtonClick}
|
||||
onHide={this.handleCreateMenuHide}
|
||||
@ -110,10 +115,10 @@ export default class AppHeader extends React.Component {
|
||||
{
|
||||
collections.valueSeq().map(collection =>
|
||||
<MenuItem
|
||||
key={collection.get('name')}
|
||||
value={collection.get('name')}
|
||||
key={collection.get("name")}
|
||||
value={collection.get("name")}
|
||||
onClick={this.handleCreatePostClick.bind(this, collection.get('name'))} // eslint-disable-line
|
||||
caption={pluralize(collection.get('label'), 1)}
|
||||
caption={pluralize(collection.get("label"), 1)}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
@ -1,3 +1,3 @@
|
||||
exports[`EntryEditorToolbar should disable and update label of Save button when persisting 1`] = `"<div><button disabled=\"\" class=\"\" data-react-toolbox=\"button\">Saving...</button> <button class=\"\" data-react-toolbox=\"button\">Cancel</button></div>"`;
|
||||
exports[`EntryEditorToolbar should disable and update label of Save button when persisting 1`] = `"<div><button disabled=\"\" class=\"\" type=\"button\" data-react-toolbox=\"button\">Saving...</button> <button class=\"\" type=\"button\" data-react-toolbox=\"button\">Cancel</button></div>"`;
|
||||
|
||||
exports[`EntryEditorToolbar should have both buttons enabled initially 1`] = `"<div><button class=\"\" data-react-toolbox=\"button\">Save</button> <button class=\"\" data-react-toolbox=\"button\">Cancel</button></div>"`;
|
||||
exports[`EntryEditorToolbar should have both buttons enabled initially 1`] = `"<div><button class=\"\" type=\"button\" data-react-toolbox=\"button\">Save</button> <button class=\"\" type=\"button\" data-react-toolbox=\"button\">Cancel</button></div>"`;
|
||||
|
@ -1,6 +1,20 @@
|
||||
export function truncateMiddle(string = '', size) {
|
||||
export function truncateMiddle(string = "", size) {
|
||||
if (string.length <= size) {
|
||||
return string;
|
||||
}
|
||||
return string.substring(0, size / 2) + '\u2026' + string.substring(string.length - size / 2 + 1, string.length);
|
||||
return `${ string.substring(0, size / 2) }\u2026${ string.substring(string.length - size / 2 + 1, string.length) }`;
|
||||
}
|
||||
|
||||
export function stringToRGB(str) {
|
||||
if (!str) return "000000";
|
||||
let hash = 0;
|
||||
for (let i = 0; i < str.length; i++) {
|
||||
hash = str.charCodeAt(i) + ((hash << 5) - hash);
|
||||
}
|
||||
|
||||
const c = (hash & 0x00FFFFFF)
|
||||
.toString(16)
|
||||
.toUpperCase();
|
||||
|
||||
return "00000".substring(0, 6 - c.length) + c;
|
||||
}
|
||||
|
Loading…
x
Reference in New Issue
Block a user