web3 first commit, message signing

This commit is contained in:
Thomas Lynch
2023-06-18 13:35:25 +10:00
parent 99fa387a03
commit c2d885c0a0
27 changed files with 1196 additions and 36 deletions
+1
View File
@@ -12,6 +12,7 @@
"gulp/res/js/watchedthread.js",
"gulp/res/js/threadwatcher.js",
"gulp/res/js/socket.io.js",
"gulp/res/js/web3.js",
"gulp/res/js/tegaki.js"
],
"env": {
+1
View File
@@ -27,6 +27,7 @@ gulp/res/js/watchedthread.js
gulp/res/js/pugruntime.js
gulp/res/js/uploaditem.js
gulp/res/js/socket.io.js
gulp/res/js/web3.js
# some ide and testing files, artefacts, etc
.idea/
+5
View File
@@ -140,6 +140,10 @@ module.exports = {
//enable the webring (also copy configs/webring.json.example -> configs/webring.json and edit)
enableWebring: false,
//web3
enableWeb3: false,
ethereumNode: '',
//extension for thumbnails
thumbExtension: '.webp',
//whether to animate gif thumbnails
@@ -395,6 +399,7 @@ module.exports = {
geoFlags: false, //show geo flags, requires nginx setup
customFlags: false, //show custom flags
enableTegaki: true,
enableWeb3: false,
userPostDelete: true, //allow users to delete their posts
userPostSpoiler: true, //allow user to spoiler their post files
userPostUnlink: true, //alow user to unlink files fomr their post
+1 -1
View File
@@ -15,7 +15,7 @@ module.exports = {
paramConverter: paramConverter({
timeFields: ['hot_threads_max_age', 'inactive_account_time', 'ban_duration', 'board_defaults_filter_ban_duration', 'default_ban_duration', 'block_bypass_expire_after_time', 'dnsbl_cache_time', 'board_defaults_delete_protection_age'],
trimFields: ['captcha_options_grid_question', 'captcha_options_grid_trues', 'captcha_options_grid_falses', 'captcha_options_font', 'allowed_hosts', 'dnsbl_blacklists', 'other_mime_types',
'highlight_options_language_subset', 'global_limits_custom_css_filters', 'board_defaults_filters', 'filters', 'archive_links', 'reverse_links', 'language', 'board_defaults_language'],
'highlight_options_language_subset', 'global_limits_custom_css_filters', 'board_defaults_filters', 'filters', 'archive_links', 'reverse_links', 'language', 'board_defaults_language', 'ethereum_node'],
numberFields: ['inactive_account_action', 'abandoned_board_action', 'filter_mode', 'auth_level', 'captcha_options_text_wave', 'captcha_options_text_paint', 'captcha_options_text_noise',
'captcha_options_grid_noise', 'captcha_options_grid_edge', 'captcha_options_generate_limit', 'captcha_options_grid_size', 'captcha_options_grid_image_size',
'captcha_options_num_distorts_min', 'captcha_options_num_distorts_max', 'captcha_options_distortion', 'captcha_options_grid_icon_y_offset', 'flood_timers_same_content_same_ip', 'flood_timers_same_content_any_ip',
+19 -3
View File
@@ -8,12 +8,13 @@ const makePost = require(__dirname+'/../../models/forms/makepost.js')
, config = require(__dirname+'/../../lib/misc/config.js')
, { Files } = require(__dirname+'/../../db/')
, paramConverter = require(__dirname+'/../../lib/middleware/input/paramconverter.js')
, { checkSchema, lengthBody, existsBody } = require(__dirname+'/../../lib/input/schema.js');
, { checkSchema, lengthBody, existsBody } = require(__dirname+'/../../lib/input/schema.js')
, web3EthAccounts = require('web3-eth-accounts');
module.exports = {
paramConverter: paramConverter({
trimFields: ['message', 'name', 'subject', 'email', 'postpassword', 'password'],
trimFields: ['message', 'name', 'subject', 'email', 'postpassword', 'password', 'signature'],
allowedArrays: ['spoiler', 'strip_filename'],
processMessageLength: true,
numberFields: ['thread'],
@@ -23,7 +24,7 @@ module.exports = {
const { __ } = res.locals;
const { globalLimits, disableAnonymizerFilePosting } = config.get;
const { globalLimits, disableAnonymizerFilePosting, enableWeb3 } = config.get;
const hasNoMandatoryFile = globalLimits.postFiles.max !== 0 && res.locals.board.settings.maxFiles !== 0 && res.locals.numFiles === 0;
const disableBoardAnonymizerFilePosting = res.locals.board.settings.disableAnonymizerFilePosting && !res.locals.permissions.get(Permissions.MANAGE_BOARD_GENERAL);
@@ -50,6 +51,21 @@ module.exports = {
{ result: lengthBody(req.body.name, 0, globalLimits.fieldLength.name), expected: false, error: __('Name must be %s characters or less', globalLimits.fieldLength.name) },
{ result: lengthBody(req.body.subject, 0, globalLimits.fieldLength.subject), expected: false, error: __('Subject must be %s characters or less', globalLimits.fieldLength.subject) },
{ result: lengthBody(req.body.email, 0, globalLimits.fieldLength.email), expected: false, error: __('Email must be %s characters or less', globalLimits.fieldLength.email) },
{ result: async () => {
if (enableWeb3 === true && res.locals.board.settings.enableWeb3 === true
&& req.body.message && req.body.signature) {
try {
const fixedMessage = req.body.rawMessage.replace(/\r\n/igm, '\n');
res.locals.recoveredAddress = await web3EthAccounts.recover(fixedMessage, req.body.signature);
return true;
} catch(e) {
console.warn(e);
return false;
}
} else {
return true;
}
}, expected: true, error: __('Failed to verify message signature') },
]);
if (errors.length > 0) {
+8 -2
View File
@@ -635,6 +635,10 @@ td {
word-break: break-all;
}
.web3-signature, .web3-address {
word-break: break-all;
}
.modal-bg {
position: fixed;
top: 0;
@@ -1536,7 +1540,8 @@ row.wrap.sb .col {
#tab-7:target .tab-7,
#tab-8:target .tab-8,
#tab-9:target .tab-9,
#tab-10:target .tab-10 {
#tab-10:target .tab-10,
#tab-11:target .tab-11 {
display: flex;
}
@@ -1549,7 +1554,8 @@ row.wrap.sb .col {
#tab-7:target a[href="#tab-7"],
#tab-8:target a[href="#tab-8"],
#tab-9:target a[href="#tab-9"],
#tab-10:target a[href="#tab-10"] {
#tab-10:target a[href="#tab-10"],
#tab-11:target a[href="#tab-11"] {
border-bottom: 1px solid var(--background-rest);
background: var(--background-rest);
}
+21
View File
@@ -118,6 +118,13 @@ class postFormHandler {
this.tegakiButton.addEventListener('click', () => this.doTegaki());
}
//if web3 signature button, attach the listener to open tegaki
this.web3Button = form.querySelector('.web3-button');
if (this.web3Button) {
this.web3Button.addEventListener('click', () => this.doWeb3Sign());
this.messageBox.addEventListener('input', () => this.form.elements.signature.value = '');
}
//if file input, attach listeners for adding files, drag+drop, etc
this.fileInput = form.querySelector('input[type="file"]');
if (this.fileInput) {
@@ -190,6 +197,20 @@ class postFormHandler {
Tegaki.setColorPalette(2); //picks a better default color palette
}
async doWeb3Sign() {
const messageContent = this.messageBox.value || '';
try {
const accounts = await window.jschanweb3.eth.requestAccounts();
const signature = await window.jschanweb3.currentProvider.request({
method: 'personal_sign',
params: [messageContent, accounts[0]],
});
this.form.elements.signature.value = signature;
} catch (e) {
console.error(e);
}
}
updateFlagField() {
if (this.customFlagInput && this.customFlagInput.options.selectedIndex !== -1) {
this.selectedFlagImage.src = this.customFlagInput.options[this.customFlagInput.options.selectedIndex].dataset.src || '';
+3
View File
@@ -8,6 +8,9 @@ window.addEventListener('DOMContentLoaded', () => {
if (e) {
e.preventDefault();
}
if (!postForm) {
return;
}
history.replaceState({}, '', '#postform');
postForm.style.display = 'flex';
topPostButton.style.visibility = 'hidden';
+8
View File
@@ -0,0 +1,8 @@
/* globals Web3 */
if (window.ethereum) {
window.jschanweb3 = new Web3(window.ethereum);
} else {
document.querySelectorAll('.web3')
.forEach(elem => elem.remove());
}
+16 -7
View File
@@ -436,6 +436,7 @@ async function custompages() {
yandexSiteKey: yandex ? yandex.siteKey : '',
globalAnnouncement: config.get.globalAnnouncement,
captchaOptions: config.get.captchaOptions,
enableWeb3: config.get.enableWeb3,
commit,
version,
globalLanguage: config.get.language,
@@ -511,16 +512,15 @@ const extraLocals = ${JSON.stringify({ meta: config.get.meta, reverseImageLinksU
fs.writeFileSync(`gulp/res/js/${templateName}.js`, compiledClient);
});
//symlink web3
await fs.symlink(__dirname+'/node_modules/web3/dist/web3.min.js', __dirname+'/gulp/res/js/web3.js', 'file')
.catch(e => { console.warn(e); });
//symlink socket.io file
fs.symlinkSync(__dirname+'/node_modules/socket.io/client-dist/socket.io.min.js', __dirname+'/gulp/res/js/socket.io.js', 'file');
await fs.symlink(__dirname+'/node_modules/socket.io/client-dist/socket.io.min.js', __dirname+'/gulp/res/js/socket.io.js', 'file')
.catch(e => { console.warn(e); });
} catch (e) {
//ignore EEXIST, probably the socket.io
if (e.code !== 'EEXIST') {
console.log(e);
}
console.log(e);
}
gulp.src([
@@ -549,10 +549,18 @@ const extraLocals = ${JSON.stringify({ meta: config.get.meta, reverseImageLinksU
`!${paths.scripts.src}/catalog.js`,
`!${paths.scripts.src}/time.js`,
`!${paths.scripts.src}/timezone.js`,
`!${paths.scripts.src}/renderweb3.js`,
])
.pipe(concat('all.js'))
.pipe(uglify({compress:true}))
.pipe(gulp.dest(paths.scripts.dest));
gulp.src([
`${paths.scripts.src}/web3.js`,
])
.pipe(concat('web3.js'))
// .pipe(uglify({compress:true})) //No need, we symlink from web3.min.js
.pipe(gulp.dest(paths.scripts.dest));
return gulp.src([
`${paths.scripts.src}/saveoverboard.js`,
@@ -564,6 +572,7 @@ const extraLocals = ${JSON.stringify({ meta: config.get.meta, reverseImageLinksU
`${paths.scripts.src}/watchlist.js`,
`${paths.scripts.src}/catalog.js`,
`${paths.scripts.src}/time.js`,
`${paths.scripts.src}/renderweb3.js`,
])
.pipe(concat('render.js'))
.pipe(uglify({compress:true}))
+3 -2
View File
@@ -16,12 +16,12 @@ const { outputFile } = require('fs-extra')
, config = require(__dirname+'/../../lib/misc/config.js');
let { language, archiveLinksURL, lockWait, globalLimits, boardDefaults, cacheTemplates,
reverseImageLinksURL, meta, enableWebring, captchaOptions, globalAnnouncement } = config.get
reverseImageLinksURL, meta, enableWebring, captchaOptions, globalAnnouncement, enableWeb3 } = config.get
, renderLocals = null;
const updateLocals = () => {
({ language, archiveLinksURL, lockWait, globalLimits, boardDefaults, cacheTemplates,
reverseImageLinksURL, meta, enableWebring, captchaOptions, globalAnnouncement } = config.get);
reverseImageLinksURL, meta, enableWebring, captchaOptions, globalAnnouncement, enableWeb3 } = config.get);
renderLocals = {
Permissions,
cache: cacheTemplates,
@@ -30,6 +30,7 @@ const updateLocals = () => {
meta,
commit,
version,
enableWeb3,
defaultTheme: boardDefaults.theme,
defaultCodeTheme: boardDefaults.codeTheme,
postFilesSize: formatSize(globalLimits.postFilesSize.max),
+4
View File
@@ -33,6 +33,10 @@ module.exports = (options) => {
processDateParam, processMessageLength, numberFields, numberArrays,
objectIdParams, objectIdFields, objectIdArrays } = options;
if (req.body.message) {
req.body.rawMessage = req.body.message;
}
/* check all body fields, body-parser prevents this array being too big, so no worry.
whitelist for fields that can be arrays, and convert singular of those fields to 1 length array */
const bodyFields = Object.keys(req.body || {});
+16
View File
@@ -0,0 +1,16 @@
'use strict';
//NOTE: unused (for now)
const { Web3 } = require('web3')
, config = require(__dirname+'/../misc/config.js')
, { addCallback } = require(__dirname+'/../redis/redis.js')
, web3 = new Web3(config.get.ethereumNode);
const updateWeb3Provider = () => {
web3.setProvider(config.get.ethereumNode);
};
addCallback('config', updateWeb3Provider);
module.exports = web3;
+2
View File
@@ -31,6 +31,7 @@ const { Boards, Posts } = require(__dirname+'/../../db/')
'allowedFileTypes.image': ['board', 'threads', 'catalog'],
'enableTegaki': ['board', 'threads', 'catalog'],
'hideBanners': ['board', 'threads', 'catalog'],
'enableWeb3': ['board', 'threads', 'catalog'],
});
module.exports = async (req, res) => {
@@ -73,6 +74,7 @@ module.exports = async (req, res) => {
'geoFlags': booleanSetting(req.body.geo_flags),
'customFlags': booleanSetting(req.body.custom_flags),
'enableTegaki': booleanSetting(req.body.enable_tegaki),
'enableWeb3': booleanSetting(req.body.enable_web3),
'forceAnon': booleanSetting(req.body.force_anon),
'sageOnlyEmail': booleanSetting(req.body.sage_only_email),
'userPostDelete': booleanSetting(req.body.user_post_delete),
+5 -2
View File
@@ -21,6 +21,7 @@ const { Boards } = require(__dirname+'/../../db/')
'archiveLinksURL': ['deletehtml', 'custompages'],
'reverseImageLinksURL': ['deletehtml', 'custompages'],
'enableWebring': ['deletehtml', 'custompages'],
'enableWeb3': ['deletehtml'],
'thumbSize': ['deletehtml', 'css', 'scripts'],
'previewReplies': ['deletehtml', 'custompages'],
'stickyPreviewReplies': ['deletehtml', 'custompages'],
@@ -140,12 +141,14 @@ module.exports = async (req, res) => {
dontStoreRawIps: booleanSetting(req.body.dont_store_raw_ips, oldSettings.dontStoreRawIps),
pruneIps: numberSetting(req.body.prune_ips, oldSettings.pruneIps),
enableWebring: booleanSetting(req.body.enable_webring, oldSettings.enableWebring),
enableWeb3: booleanSetting(req.body.enable_web3, oldSettings.enableWeb3),
ethereumNode: trimSetting(req.body.ethereum_node, oldSettings.ethereumNode),
following: arraySetting(req.body.webring_following, oldSettings.following),
blacklist: arraySetting(req.body.webring_blacklist, oldSettings.blacklist),
logo: arraySetting(req.body.webring_logos, oldSettings.logo),
proxy: {
enabled: booleanSetting(req.body.webring_proxy_enabled, oldSettings.proxy.enabled),
address: trimSetting(req.body.webring_proxy_address, oldSettings.proxy.address),
enabled: booleanSetting(req.body.proxy_enabled, oldSettings.proxy.enabled),
address: trimSetting(req.body.proxy_address, oldSettings.proxy.address),
},
thumbExtension: trimSetting(req.body.thumb_extension, oldSettings.thumbExtension),
highlightOptions: {
+10
View File
@@ -427,6 +427,14 @@ module.exports = async (req, res) => {
const nomarkup = prepareMarkdown(req.body.message, true);
const { message, quotes, crossquotes } = await messageHandler(nomarkup, req.params.board, req.body.thread, res.locals.permissions);
//web3 sig
let signature = null
, address = null;
if (res.locals.recoveredAddress) {
signature = req.body.signature;
address = res.locals.recoveredAddress;
}
//build post data for db. for some reason all the property names are lower case :^)
const now = Date.now();
const data = {
@@ -445,6 +453,8 @@ module.exports = async (req, res) => {
password,
email,
spoiler,
signature,
address,
'banmessage': null,
userId,
'ip': res.locals.ip,
+1012 -4
View File
File diff suppressed because it is too large Load Diff
+3 -1
View File
@@ -54,7 +54,9 @@
"socket.io": "^4.6.1",
"socks-proxy-agent": "^6.2.1",
"uid-safe": "^2.1.5",
"unix-crypt-td-js": "^1.1.4"
"unix-crypt-td-js": "^1.1.4",
"web3": "^4.0.1",
"web3-eth-accounts": "^4.0.1"
},
"devDependencies": {
"eslint": "^8.36.0",
+2 -1
View File
@@ -68,7 +68,7 @@ const config = require(__dirname+'/lib/misc/config.js')
const loadAppLocals = () => {
const { language, cacheTemplates, boardDefaults, globalLimits, captchaOptions, archiveLinksURL,
reverseImageLinksURL, meta, enableWebring, globalAnnouncement } = config.get;
reverseImageLinksURL, meta, enableWebring, globalAnnouncement, enableWeb3 } = config.get;
//cache loaded templates
app.cache = {};
app[cacheTemplates === true ? 'enable' : 'disable']('view cache');
@@ -80,6 +80,7 @@ const config = require(__dirname+'/lib/misc/config.js')
app.locals.archiveLinksURL = archiveLinksURL;
app.locals.reverseImageLinksURL = reverseImageLinksURL;
app.locals.enableWebring = enableWebring;
app.locals.enableWeb3 = enableWeb3;
app.locals.commit = commit;
app.locals.version = version;
app.locals.meta = meta;
+1 -1
View File
@@ -238,7 +238,7 @@ module.exports = () => describe('login and create test board', () => {
global_limits_asset_files_size_max: '10485760',
global_limits_asset_files_max: '10',
global_limits_asset_files_total: '50',
webring_proxy_address: '',
proxy_address: '',
webring_following: '',
webring_logos: '',
webring_blacklist: '',
+4
View File
@@ -39,6 +39,10 @@ script(src=`/js/lang/${pageLanguage}.js?v=${commit}`)
//- main script
script(src=`/js/all.js?v=${commit}&ct=${captchaOptions.type}`)
//- optional web3 script
if enableWeb3 || isBoard && board.settings.enableWeb3
script(src=`/js/web3.js?v=${commit}`)
//- additional scripts included only if hcaptcha, recaptcha, or yandex smartcaptcha are used
if captchaOptions.type === 'google'
script(src='https://www.google.com/recaptcha/api.js' async defer)
+5
View File
@@ -40,6 +40,11 @@ section.form-wrapper.flex-center
- const minLength = (isThread ? board.settings.minReplyMessageLength : board.settings.minThreadMessageLength) || 0;
- const maxLength = Math.min((isThread ? board.settings.maxReplyMessageLength : board.settings.maxThreadMessageLength), globalLimits.fieldLength.message) || globalLimits.fieldLength.message;
textarea#message(name='message', rows='5', autocomplete='off' minlength=minLength maxlength=maxLength required=messageRequired)
if enableWeb3 === true && board.settings.enableWeb3 === true
section.row.jsonly.web3
.label #{__('Web3')}
input.dummy-link.web3-button(type='button', value=__('Sign'))
textarea.ml-1.no-resize(name='signature', rows='1', autocomplete='off' readonly)
if board.settings.maxFiles > 0 && Object.values(board.settings.allowedFileTypes).some(x => x === true)
- const maxFiles = board.settings.maxFiles;
section.row
+2
View File
@@ -0,0 +1,2 @@
include ../mixins/web3signature.pug
+web3signature(signature, address)
+3
View File
@@ -1,5 +1,6 @@
include ./report.pug
include ./banmessage.pug
include ./web3signature.pug
mixin post(post, truncate, manage=false, globalmanage=false, ban=false, overboard=false)
.anchor(id=post.postId)
div(class=`post-container ${post.thread || ban === true ? '' : 'op'}`
@@ -162,6 +163,8 @@ mixin post(post, truncate, manage=false, globalmanage=false, ban=false, overboar
| #{lastEdited[1]}
if post.banmessage
+banmessage(post.banmessage)
if post.signature && post.address
+web3signature(post.signature, post.address)
if truncatedMessage !== post.message
div.cb.mt-5.ml-5
| #{__('Message too long.')} #[a.viewfulltext(href=`${postURL}#${post.postId}`) #{__('View the full text')}]
+3
View File
@@ -0,0 +1,3 @@
mixin web3signature(signature, address)
.web3-address #{__('Address: %s', address)}
.web3-signature #{__('Signature: %s', signature)}
+34 -12
View File
@@ -54,11 +54,13 @@ block content
li
a(href='#tab-7') #{__('Webring')}
li
a(href='#tab-8') #{__('Files & Thumbnails')}
a(href='#tab-8') #{__('Web3')}
li
a(href='#tab-9') #{__('Frontend Script Defaults')}
a(href='#tab-9') #{__('Files & Thumbnails')}
li
a(href='#tab-10') #{__('Board Defaults')}
a(href='#tab-10') #{__('Frontend Script Defaults')}
li
a(href='#tab-11') #{__('Board Defaults')}
.box-wrap
.tab.tab-1
.col
@@ -170,6 +172,13 @@ block content
.row
.label #{__('Prune IPs Days')}
input(type='number', name='prune_ips' value=settings.pruneIps)
.row
.label #{__('Use Socks Proxy')}
label.postform-style.ph-5
input(type='checkbox', name='proxy_enabled', value='true' placeholder='socks5h://127.0.0.1:9050' checked=settings.proxy.enabled)
.row
.label #{__('Proxy Address')}
input(type='text', name='proxy_address', value=settings.proxy.address)
.tab.tab-3
.col
@@ -527,13 +536,6 @@ block content
.label #{__('Enable')}
label.postform-style.ph-5
input(type='checkbox', name='enable_webring' value='true' checked=settings.enableWebring)
.row
.label #{__('Use Socks Proxy')}
label.postform-style.ph-5
input(type='checkbox', name='webring_proxy_enabled', value='true' placeholder='socks5h://127.0.0.1:9050' checked=settings.proxy.enabled)
.row
.label #{__('Proxy Address')}
input(type='text', name='webring_proxy_address', value=settings.proxy.address)
.row
.label #{__('Following')}
textarea(name='webring_following' placeholder=__('Newline separated')) #{settings.following.join('\n')}
@@ -545,6 +547,22 @@ block content
textarea(name='webring_blacklist' placeholder=__('Newline separated')) #{settings.blacklist.join('\n')}
.tab.tab-8
.col
.row
.label #{__('Enable')}
label.postform-style.ph-5
input(type='checkbox', name='enable_web3' value='true' checked=settings.enableWeb3)
.row
.label
| #{__('Ethereum Node')}
|
small
| (
a(rel='nofollow' referrerpolicy='same-origin' target='_blank' href='https://ethereumnodes.com/') #{__('Nodes')}
| )
input(type='text' name='ethereum_node' value=settings.ethereumNode)
.tab.tab-9
.col
.row
.label #{__('Animated .gif Thumbnails')}
@@ -588,7 +606,7 @@ block content
.label #{__('Video Max Resolution')}
input(type='text' name='global_limits_post_files_size_video_resolution' value=settings.globalLimits.postFilesSize.videoResolution)
.tab.tab-9
.tab.tab-10
.col
.row
.label #{__('Embeds Enabled')}
@@ -685,7 +703,7 @@ block content
.label #{__('Tegaki Height')}
input(type='number', name='frontend_script_default_tegaki_height', value=settings.frontendScriptDefault.tegakiHeight)
.tab.tab-10
.tab.tab-11
.col
.row
.label #{__('SFW')}
@@ -793,6 +811,10 @@ block content
.label #{__('Enable Tegaki')}
label.postform-style.ph-5
input(type='checkbox', name='board_defaults_enable_tegaki', value='true' checked=settings.boardDefaults.enableTegaki)
.row
.label #{__('Enable Web3')}
label.postform-style.ph-5
input(type='checkbox', name='board_defaults_enable_web3', value='true' checked=settings.boardDefaults.enableWeb3)
.row
.label #{__('User Post Deletion')}
label.postform-style.ph-5
+4
View File
@@ -118,6 +118,10 @@ block content
.label #{__('Enable Tegaki')}
label.postform-style.ph-5
input(type='checkbox', name='enable_tegaki', value='true' checked=board.settings.enableTegaki)
.row
.label #{__('Enable Web3')}
label.postform-style.ph-5
input(type='checkbox', name='enable_web3', value='true' checked=board.settings.enableWeb3)
.row
.label #{__('SFW')}
label.postform-style.ph-5