Revert: commits for user metadata changes (#1887)

This commit is contained in:
Daniel Salazar
2025-11-02 06:09:13 -08:00
committed by GitHub
parent 823c06c371
commit 758bef0582
30 changed files with 1419 additions and 1844 deletions

View File

@@ -67,7 +67,7 @@ in order to access `db` from callbacks.
```javascript
const ext = extension;
extension.get('/user-count', { noauth: true, mw: [] }, (req, res) => {
extension.get('/user-count', { noauth: true }, (req, res) => {
const [count] = await ext.db.read(
'SELECT COUNT(*) as c FROM `user`'
);

View File

@@ -45,7 +45,6 @@ const rules = {
'no-undef': 'error',
'custom/control-structure-spacing': 'error',
'@stylistic/no-trailing-spaces': 'error',
'@stylistic/space-before-blocks': ['error', 'always'],
};
export default defineConfig([
@@ -141,7 +140,48 @@ export default defineConfig([
i18n: 'readonly',
},
},
rules,
rules: {
'no-unused-vars': ['error', {
'vars': 'all',
'args': 'after-used',
'caughtErrors': 'all',
'ignoreRestSiblings': false,
'ignoreUsingDeclarations': false,
'reportUsedIgnorePattern': false,
'argsIgnorePattern': '^_',
'caughtErrorsIgnorePattern': '^_',
'destructuredArrayIgnorePattern': '^_',
}],
'@stylistic/curly-newline': ['error', 'always'],
'@stylistic/object-curly-spacing': ['error', 'always'],
'@stylistic/indent': ['error', 4, {
'CallExpression': { arguments: 4 },
}],
'@stylistic/indent-binary-ops': ['error', 4],
'@stylistic/array-bracket-newline': ['error', 'consistent'],
'@stylistic/semi': ['error', 'always'],
'@stylistic/quotes': ['error', 'single', { 'avoidEscape': true }],
'@stylistic/function-call-argument-newline': ['error', 'consistent'],
'@stylistic/arrow-spacing': ['error', { before: true, after: true }],
'@stylistic/space-before-function-paren': ['error', { 'anonymous': 'never', 'named': 'never', 'asyncArrow': 'always', 'catch': 'never' }],
'@stylistic/key-spacing': ['error', { 'beforeColon': false, 'afterColon': true }],
'@stylistic/keyword-spacing': ['error', { 'before': true, 'after': true }],
'@stylistic/no-multiple-empty-lines': ['error', { max: 1, maxEOF: 0 }],
'@stylistic/comma-spacing': ['error', { 'before': false, 'after': true }],
'@stylistic/comma-dangle': ['error', 'always-multiline'],
'@stylistic/object-property-newline': ['error', { allowAllPropertiesOnSameLine: true }],
'@stylistic/dot-location': ['error', 'property'],
'@stylistic/space-infix-ops': ['error'],
'no-template-curly-in-string': 'error',
'prefer-template': 'error',
'no-undef': 'error',
'no-useless-concat': 'error',
'template-curly-spacing': ['error', 'never'],
curly: ['error', 'multi-line'],
'custom/control-structure-spacing': 'error',
'@stylistic/no-trailing-spaces': 'error',
},
},
{
files: ['**/*.{js,mjs,cjs,ts}'],
@@ -153,7 +193,47 @@ export default defineConfig([
i18n: 'readonly',
},
},
rules,
rules: {
'no-unused-vars': ['error', {
'vars': 'all',
'args': 'after-used',
'caughtErrors': 'all',
'ignoreRestSiblings': false,
'ignoreUsingDeclarations': false,
'reportUsedIgnorePattern': false,
'argsIgnorePattern': '^_',
'caughtErrorsIgnorePattern': '^_',
'destructuredArrayIgnorePattern': '^_',
}],
'@stylistic/curly-newline': ['error', 'always'],
'@stylistic/object-curly-spacing': ['error', 'always'],
'@stylistic/indent': ['error', 4, {
'CallExpression': { arguments: 4 },
}],
'@stylistic/indent-binary-ops': ['error', 4],
'@stylistic/array-bracket-newline': ['error', 'consistent'],
'@stylistic/semi': ['error', 'always'],
'@stylistic/quotes': ['error', 'single', { 'avoidEscape': true }],
'@stylistic/function-call-argument-newline': ['error', 'consistent'],
'@stylistic/arrow-spacing': ['error', { before: true, after: true }],
'@stylistic/space-before-function-paren': ['error', { 'anonymous': 'never', 'named': 'never', 'asyncArrow': 'always', 'catch': 'never' }],
'@stylistic/key-spacing': ['error', { 'beforeColon': false, 'afterColon': true }],
'@stylistic/keyword-spacing': ['error', { 'before': true, 'after': true }],
'@stylistic/no-multiple-empty-lines': ['error', { max: 1, maxEOF: 0 }],
'@stylistic/comma-spacing': ['error', { 'before': false, 'after': true }],
'@stylistic/comma-dangle': ['error', 'always-multiline'],
'@stylistic/object-property-newline': ['error', { allowAllPropertiesOnSameLine: true }],
'@stylistic/dot-location': ['error', 'property'],
'@stylistic/space-infix-ops': ['error'],
'no-template-curly-in-string': 'error',
'prefer-template': 'error',
'no-undef': 'error',
'no-useless-concat': 'error',
'template-curly-spacing': ['error', 'never'],
curly: ['error', 'multi-line'],
'custom/control-structure-spacing': 'error',
'@stylistic/no-trailing-spaces': 'error',
},
extends: ['js/recommended'],
plugins: {
js,

View File

@@ -1,2 +0,0 @@
*.js
*.map

View File

@@ -1,22 +0,0 @@
{
"name": "extensionController",
"priority": -1000,
"version": "1.0.0",
"description": "",
"main": "src/index.js",
"type": "module",
"scripts": {
"test": "echo \"Error: no test specified\" && exit 1"
},
"keywords": [],
"author": "",
"license": "ISC",
"devDependencies": {
"@types/express": "^5.0.3",
"@types/node": "^24.9.1",
"ts-node": "^10.9.2"
},
"dependencies": {
"stripe": "^19.1.0"
}
}

View File

@@ -1,71 +0,0 @@
import type { Request, Response } from 'express';
import type { EndpointOptions, HttpMethod } from '../../api.d.ts';
/**
* Controller decorator to set prefix on prototype and register routes on instantiation
*/
export const Controller = (prefix: string): ClassDecorator => {
return (target: Function) => {
target.prototype.__controllerPrefix = prefix;
};
};
/**
* Method decorator factory that collects route metadata
*/
interface RouteMeta {
method: HttpMethod;
path: string;
options?: EndpointOptions | undefined;
handler: (req: Request, res: Response)=> void | Promise<void>;
}
const createMethodDecorator = <This>(method: HttpMethod) => {
return (path: string, options?: EndpointOptions) => {
return (target: (req: Request, res: Response)=> void | Promise<void>, _context: ClassMethodDecoratorContext<This, (this:This, ...args:[req: Request, res: Response])=> void | Promise<void>>) => {
_context.addInitializer(function() {
const proto = Object.getPrototypeOf(this);
if ( !proto.__routes ) {
proto.__routes = [];
}
proto.__routes.push({
method,
path,
options: options as EndpointOptions | undefined,
handler: target,
});
});
};
};
};
// HTTP method decorators
export const Get = createMethodDecorator('get');
export const Post = createMethodDecorator('post');
export const Put = createMethodDecorator('put');
export const Delete = createMethodDecorator('delete');
// TODO DS: add others as needed (patch, etc)
// Registers all routes from a decorated controller instance to an Express router
export class ExtensionController {
// TODO DS: make this work with other express-like routers
registerRoutes() {
const prefix = Object.getPrototypeOf(this).__controllerPrefix || '';
const routes: RouteMeta[] = Object.getPrototypeOf(this).__routes || [];
for ( const route of routes ) {
const fullPath = `${prefix}/${route.path}`.replace(/\/+/g, '/');
if ( !extension[route.method] ){
throw new Error(`Unsupported HTTP method: ${route.method}`);
} else {
console.log(`Registering route: [${route.method.toUpperCase()}] ${fullPath}`);
// eslint-disable-next-line @typescript-eslint/no-explicit-any
(extension[route.method] as any)(fullPath, route.options, route.handler.bind(this));
}
}
}
}

View File

@@ -1,4 +0,0 @@
//@puter priority -1000
import * as extensionControllerExports from './ExtensionController.js';
extension.exports = { ...extensionControllerExports };

View File

@@ -1,39 +0,0 @@
{
// Visit https://aka.ms/tsconfig to read more about this file
"compilerOptions": {
// File Layout
"rootDir": "./src",
// "outDir": "./dist",
// Environment Settings
// See also https://aka.ms/tsconfig/module
"module": "nodenext",
"target": "esnext",
"types": [],
// For nodejs:
// "lib": ["esnext"],
// "types": ["node"],
// and npm install -D @types/node
// Other Outputs
"sourceMap": true,
"declaration": true,
"declarationMap": true,
// Stricter Typechecking Options
"noUncheckedIndexedAccess": true,
"exactOptionalPropertyTypes": true,
// Style Options
// "noImplicitReturns": true,
// "noImplicitOverride": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"noFallthroughCasesInSwitch": true,
// "noPropertyAccessFromIndexSignature": true,
// Recommended Options
"strict": true,
"jsx": "react-jsx",
"verbatimModuleSyntax": true,
"isolatedModules": true,
"noUncheckedSideEffectImports": true,
"moduleDetection": "force",
"skipLibCheck": true,
}
}

18
extensions/api.d.ts vendored
View File

@@ -1,23 +1,17 @@
import type { Actor } from '@heyputer/backend/src/services/auth/Actor.js';
import type { BaseDatabaseAccessService } from '@heyputer/backend/src/services/database/BaseDatabaseAccessService.d.ts';
import type { MeteringService } from '@heyputer/backend/src/services/MeteringService/MeteringService.ts';
import type { MeteringServiceWrapper } from '@heyputer/backend/src/services/MeteringService/MeteringServiceWrapper.mjs';
import type { DBKVStore } from '@heyputer/backend/src/services/repositories/DBKVStore/DBKVStore.ts';
import type { SUService } from '@heyputer/backend/src/services/SUService.js';
import type { IUser } from '@heyputer/backend/src/services/User.js';
import type { UserService } from '@heyputer/backend/src/services/UserService.d.ts';
import type { RequestHandler } from 'express';
import type FSNodeContext from '../src/backend/src/filesystem/FSNodeContext.js';
import type helpers from '../src/backend/src/helpers.js';
import type * as ExtensionControllerExports from './ExtensionController/src/ExtensionController.ts';
declare global {
namespace Express {
interface Request {
services: { get: <T extends (keyof ServiceNameMap ) | (string & {})>(string: T)=> T extends keyof ServiceNameMap ? ServiceNameMap[T] : unknown }
actor: Actor,
/** @deprecated use actor instead */
user: IUser
actor: Actor
}
}
}
@@ -26,11 +20,6 @@ interface EndpointOptions {
allowedMethods?: string[]
subdomain?: string
noauth?: boolean
mw?: RequestHandler[]
otherOpts?: Record<string, unknown> & {
json?: boolean
noReallyItsJson?: boolean
}
}
type HttpMethod = 'get' | 'post' | 'put' | 'delete' | 'patch';
@@ -61,15 +50,10 @@ interface ServiceNameMap {
'meteringService': Pick<MeteringServiceWrapper, 'meteringService'> & MeteringService // TODO DS: squash into a single class without wrapper
'puter-kvstore': DBKVStore
'su': SUService
'database': BaseDatabaseAccessService
'user': UserService
}
interface Extension extends RouterMethods {
exports: Record<string, unknown>,
on<T extends unknown[]>(event: string, listener: (...args: T)=> void): void, // TODO DS: type events better
import(module:'core'): CoreRuntimeModule,
import(module:'fs'): FilesystemModule,
import(module:'extensionController'): typeof ExtensionControllerExports
import<T extends `service:${keyof ServiceNameMap}`| (string & {})>(module: T): T extends `service:${infer R extends keyof ServiceNameMap}`
? ServiceNameMap[R]
: unknown;

View File

@@ -47,7 +47,7 @@ const whoami_common = ({ is_user, user }) => {
epoch = new Date(user.last_activity_ts).getTime();
// round to 1 decimal place
epoch = Math.round(epoch / 1000);
} catch ( e ) {
} catch (e) {
console.error('Error parsing last_activity_ts', e);
}
@@ -94,7 +94,6 @@ extension.get('/whoami', { subdomain: 'api' }, async (req, res, next) => {
referral_code: req.user.referral_code,
otp: !! req.user.otp_enabled,
human_readable_age: timeago.format(new Date(req.user.timestamp)),
hasDevAccountAccess: !! req.user.metadata?.hasDevAccountAccess,
...(req.new_token ? { token: req.token } : {}),
};
@@ -149,35 +148,37 @@ extension.post('/whoami', { subdomain: 'api' }, async (req, res) => {
let desktop_items = [];
// check if user asked for desktop items
if ( req.query.return_desktop_items === 1 || req.query.return_desktop_items === '1' || req.query.return_desktop_items === 'true' ){
if(req.query.return_desktop_items === 1 || req.query.return_desktop_items === '1' || req.query.return_desktop_items === 'true'){
// by cached desktop id
if ( req.user.desktop_id ){
if(req.user.desktop_id){
// TODO: Check if used anywhere, maybe remove
// eslint-disable-next-line no-undef
desktop_items = await db.read(`SELECT * FROM fsentries
desktop_items = await db.read(
`SELECT * FROM fsentries
WHERE user_id = ? AND parent_uid = ?`,
[req.user.id, await id2uuid(req.user.desktop_id)]);
[req.user.id, await id2uuid(req.user.desktop_id)]
)
}
// by desktop path
else {
desktop_items = await get_descendants(req.user.username + '/Desktop', req.user, 1, true);
else{
desktop_items = await get_descendants(req.user.username +'/Desktop', req.user, 1, true);
}
// clean up desktop items and add some extra information
if ( desktop_items.length > 0 ){
if ( desktop_items.length > 0 ){
for ( let i = 0; i < desktop_items.length; i++ ) {
if ( desktop_items[i].id !== null ){
if(desktop_items.length > 0){
if(desktop_items.length > 0){
for (let i = 0; i < desktop_items.length; i++) {
if(desktop_items[i].id !== null){
// suggested_apps for files
if ( !desktop_items[i].is_dir ){
desktop_items[i].suggested_apps = await suggest_app_for_fsentry(desktop_items[i], { user: req.user });
if(!desktop_items[i].is_dir){
desktop_items[i].suggested_apps = await suggest_app_for_fsentry(desktop_items[i], {user: req.user});
}
// is_shared
desktop_items[i].is_shared = await is_shared_with_anyone(desktop_items[i].id);
// associated_app
if ( desktop_items[i].associated_app_id ){
const app = await get_app({ id: desktop_items[i].associated_app_id });
if(desktop_items[i].associated_app_id){
const app = await get_app({id: desktop_items[i].associated_app_id})
// remove some privileged information
delete app.id;
@@ -188,7 +189,7 @@ extension.post('/whoami', { subdomain: 'api' }, async (req, res) => {
// add to array
desktop_items[i].associated_app = app;
} else {
}else{
desktop_items[i].associated_app = {};
}
@@ -199,7 +200,7 @@ extension.post('/whoami', { subdomain: 'api' }, async (req, res) => {
delete desktop_items[i].id;
delete desktop_items[i].user_id;
delete desktop_items[i].bucket;
desktop_items[i].path = _path.join('/', req.user.username, desktop_items[i].name);
desktop_items[i].path = _path.join('/', req.user.username, desktop_items[i].name)
}
}
}
@@ -220,6 +221,5 @@ extension.post('/whoami', { subdomain: 'api' }, async (req, res) => {
taskbar_items: await get_taskbar_items(req.user),
desktop_items: desktop_items,
referral_code: req.user.referral_code,
hasDevAccountAccess: !! req.user.metadata?.hasDevAccountAccess,
}, whoami_common({ is_user, user: req.user })));
});

View File

@@ -1,27 +1,27 @@
/*
* Copyright (C) 2024-present Puter Technologies Inc.
*
*
* This file is part of Puter.
*
*
* Puter is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published
* by the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
const { AdvancedBase } = require('@heyputer/putility');
const EmitterFeature = require('@heyputer/putility/src/features/EmitterFeature');
const { Context } = require('./util/context');
const { ExtensionServiceState } = require('./ExtensionService');
const { display_time } = require('@heyputer/putility/src/libs/time');
const { AdvancedBase } = require("@heyputer/putility");
const EmitterFeature = require("@heyputer/putility/src/features/EmitterFeature");
const { Context } = require("./util/context");
const { ExtensionServiceState } = require("./ExtensionService");
const { display_time } = require("@heyputer/putility/src/libs/time");
/**
* This class creates the `extension` global that is seen by Puter backend
@@ -33,11 +33,11 @@ class Extension extends AdvancedBase {
decorators: [
fn => Context.get(undefined, {
allow_fallback: true,
}).abind(fn),
],
}).abind(fn)
]
}),
];
randomBrightColor() {
// Bright colors in ANSI (foreground codes 9097)
const brightColors = [
@@ -52,30 +52,30 @@ class Extension extends AdvancedBase {
return brightColors[Math.floor(Math.random() * brightColors.length)];
}
constructor(...a) {
constructor (...a) {
super(...a);
this.service = null;
this.log = null;
this.ensure_service_();
// this.terminal_color = this.randomBrightColor();
this.terminal_color = 94;
this.log = (...a) => {
this.log_context.info(a.join(' '));
};
this.LOG = (...a) => {
this.log_context.noticeme(a.join(' '));
};
['info', 'warn', 'debug', 'error', 'tick', 'noticeme', 'system'].forEach(lvl => {
['info','warn','debug','error','tick','noticeme','system'].forEach(lvl => {
this.log[lvl] = (...a) => {
this.log_context[lvl](...a);
};
}
});
this.only_one_preinit_fn = null;
this.only_one_init_fn = null;
this.registry = {
register: this.register.bind(this),
of: (typeKey) => {
@@ -90,23 +90,23 @@ class Extension extends AdvancedBase {
...Object.values(this.registry_[typeKey].named),
...this.registry_[typeKey].anonymous,
],
};
},
}
}
};
}
example() {
example () {
console.log('Example method called by an extension.');
}
// === [START] RuntimeModule aliases ===
set exports(value) {
set exports (value) {
this.runtime.exports = value;
}
get exports() {
get exports () {
return this.runtime.exports;
}
import(name) {
import (name) {
return this.runtime.import(name);
}
// === [END] RuntimeModule aliases ===
@@ -114,53 +114,59 @@ class Extension extends AdvancedBase {
/**
* This will get a database instance from the default service.
*/
get db() {
get db () {
const db = this.service.values.get('db');
if ( ! db ) {
throw new Error('extension tried to access database before it was ' +
'initialized');
throw new Error(
'extension tried to access database before it was ' +
'initialized'
);
}
return db;
}
get services() {
get services () {
const services = this.service.values.get('services');
if ( ! services ) {
throw new Error('extension tried to access "services" before it was ' +
'initialized');
throw new Error(
'extension tried to access "services" before it was ' +
'initialized'
);
}
return services;
}
get log_context() {
get log_context () {
const log_context = this.service.values.get('log_context');
if ( ! log_context ) {
throw new Error('extension tried to access "log_context" before it was ' +
'initialized');
throw new Error(
'extension tried to access "log_context" before it was ' +
'initialized'
);
}
return log_context;
}
/**
* Register anonymous or named data to a particular type/category.
* @param {string} typeKey Type of data being registered
* @param {string} [key] Key of data being registered
* @param {any} data The data to be registered
*/
register(typeKey, keyOrData, data) {
register (typeKey, keyOrData, data) {
if ( ! this.registry_[typeKey] ) {
this.registry_[typeKey] = {
named: {},
anonymous: [],
};
}
const typeRegistry = this.registry_[typeKey];
if ( arguments.length <= 1 ) {
throw new Error('you must specify what to register');
}
if ( arguments.length === 2 ) {
data = keyOrData;
if ( Array.isArray(data) ) {
@@ -172,28 +178,28 @@ class Extension extends AdvancedBase {
typeRegistry.anonymous.push(data);
return;
}
const key = keyOrData;
typeRegistry.named[key] = data;
}
/**
* Alias for .register()
* @param {string} typeKey Type of data being registered
* @param {string} [key] Key of data being registered
* @param {any} data The data to be registered
*/
reg(...a) {
reg (...a) {
this.register(...a);
}
/**
* This will create a GET endpoint on the default service.
* @param {*} path - route for the endpoint
* @param {*} handler - function to handle the endpoint
* @param {*} options - options like noauth (bool) and mw (array)
*/
get(path, handler, options) {
get (path, handler, options) {
// this extension will have a default service
this.ensure_service_();
@@ -215,7 +221,7 @@ class Extension extends AdvancedBase {
* @param {*} handler - function to handle the endpoint
* @param {*} options - options like noauth (bool) and mw (array)
*/
post(path, handler, options) {
post (path, handler, options) {
// this extension will have a default service
this.ensure_service_();
@@ -230,52 +236,8 @@ class Extension extends AdvancedBase {
methods: ['POST'],
});
}
/**
* This will create a DELETE endpoint on the default service.
* @param {*} path - route for the endpoint
* @param {*} handler - function to handle the endpoint
* @param {*} options - options like noauth (bool) and mw (array)
*/
put(path, handler, options) {
// this extension will have a default service
this.ensure_service_();
// handler and options may be flipped
if ( typeof handler === 'object' ) {
[handler, options] = [options, handler];
}
if ( ! options ) options = {};
this.service.register_route_handler_(path, handler, {
...options,
methods: ['PUT'],
});
}
/**
* This will create a DELETE endpoint on the default service.
* @param {*} path - route for the endpoint
* @param {*} handler - function to handle the endpoint
* @param {*} options - options like noauth (bool) and mw (array)
*/
delete(path, handler, options) {
// this extension will have a default service
this.ensure_service_();
// handler and options may be flipped
if ( typeof handler === 'object' ) {
[handler, options] = [options, handler];
}
if ( ! options ) options = {};
this.service.register_route_handler_(path, handler, {
...options,
methods: ['DELETE'],
});
}
use(...args) {
use (...args) {
this.ensure_service_();
this.service.expressThings_.push({
type: 'router',
@@ -284,7 +246,7 @@ class Extension extends AdvancedBase {
}
get preinit() {
return (function(callback) {
return (function (callback) {
this.on('preinit', callback);
}).bind(this);
}
@@ -295,8 +257,7 @@ class Extension extends AdvancedBase {
});
}
if ( callback === null ) {
this.only_one_preinit_fn = () => {
};
this.only_one_preinit_fn = () => {};
}
this.only_one_preinit_fn = callback;
}
@@ -306,20 +267,19 @@ class Extension extends AdvancedBase {
this.on('init', callback);
}).bind(this);
}
set init(callback) {
set init (callback) {
if ( this.only_one_init_fn === null ) {
this.on('init', (...a) => {
this.only_one_init_fn(...a);
});
}
if ( callback === null ) {
this.only_one_init_fn = () => {
};
this.only_one_init_fn = () => {};
}
this.only_one_init_fn = callback;
}
get console() {
get console () {
const extensionConsole = Object.create(console);
const logfn = level => (...a) => {
let svc_log;
@@ -357,10 +317,10 @@ class Extension extends AdvancedBase {
* This method will create the "default service" for an extension.
* This is specifically for Puter extensions that do not define their
* own service classes.
*
*
* @returns {void}
*/
ensure_service_() {
ensure_service_ () {
if ( this.service ) {
return;
}
@@ -373,4 +333,4 @@ class Extension extends AdvancedBase {
module.exports = {
Extension,
};
}

View File

@@ -1,29 +1,29 @@
/*
* Copyright (C) 2024-present Puter Technologies Inc.
*
*
* This file is part of Puter.
*
*
* Puter is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published
* by the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
const { AdvancedBase } = require('@heyputer/putility');
const BaseService = require('./services/BaseService');
const { Endpoint } = require('./util/expressutil');
const configurable_auth = require('./middleware/configurable_auth');
const { Context } = require('./util/context');
const { DB_WRITE } = require('./services/database/consts');
const { Actor } = require('./services/auth/Actor');
const { AdvancedBase } = require("@heyputer/putility");
const BaseService = require("./services/BaseService");
const { Endpoint } = require("./util/expressutil");
const configurable_auth = require("./middleware/configurable_auth");
const { Context } = require("./util/context");
const { DB_WRITE } = require("./services/database/consts");
const { Actor } = require("./services/auth/Actor");
/**
* State shared with the default service and the `extension` global so that
@@ -31,17 +31,17 @@ const { Actor } = require('./services/auth/Actor');
* future) to the default service.
*/
class ExtensionServiceState extends AdvancedBase {
constructor(...a) {
constructor (...a) {
super(...a);
this.extension = a[0].extension;
this.expressThings_ = [];
// Values shared between the `extension` global and its service
this.values = new Context();
}
register_route_handler_(path, handler, options = {}) {
register_route_handler_ (path, handler, options = {}) {
// handler and options may be flipped
if ( typeof handler === 'object' ) {
[handler, options] = [options, handler];
@@ -64,9 +64,8 @@ class ExtensionServiceState extends AdvancedBase {
route: path,
handler: handler,
...(options.subdomain ? { subdomain: options.subdomain } : {}),
otherOpts: options.otherOpts || {},
});
this.expressThings_.push({ type: 'endpoint', value: endpoint });
}
}
@@ -77,15 +76,15 @@ class ExtensionServiceState extends AdvancedBase {
* provide a default service for extensions.
*/
class ExtensionService extends BaseService {
_construct() {
_construct () {
this.expressThings_ = [];
}
async _init(args) {
async _init (args) {
this.state = args.state;
this.state.values.set('services', this.services);
this.state.values.set('log_context', this.services.get('log-service').create(
this.state.extension.name));
this.state.extension.name));
// Create database access object for extension
const db = this.services.get('database').get(DB_WRITE, 'extension');
@@ -114,20 +113,20 @@ class ExtensionService extends BaseService {
// Propagate all events from extension to Puter's event bus
this.state.extension.on_all(async (key, data, meta) => {
if ( meta.from_outside_of_extension ) return;
await svc_event.emit(key, data, meta);
});
this.state.extension.kv = (() => {
const impls = this.services.get_implementors('puter-kvstore');
const impl_kv = impls[0].impl;
return new Proxy(impl_kv, {
get: (target, prop) => {
if ( typeof target[prop] !== 'function' ) {
return target[prop];
}
return (...args) => {
if ( typeof args[0] !== 'object' ) {
// Luckily named parameters don't have positional
@@ -153,7 +152,7 @@ class ExtensionService extends BaseService {
this.state.extension.emit('preinit');
}
async ['__on_boot.consolidation'](...a) {
async ['__on_boot.consolidation'] (...a) {
const svc_su = this.services.get('su');
await svc_su.sudo(async () => {
await this.state.extension.emit('init', {}, {
@@ -161,7 +160,7 @@ class ExtensionService extends BaseService {
});
});
}
async ['__on_boot.activation'](...a) {
async ['__on_boot.activation'] (...a) {
const svc_su = this.services.get('su');
await svc_su.sudo(async () => {
await this.state.extension.emit('activate', {}, {
@@ -169,7 +168,7 @@ class ExtensionService extends BaseService {
});
});
}
async ['__on_boot.ready'](...a) {
async ['__on_boot.ready'] (...a) {
const svc_su = this.services.get('su');
await svc_su.sudo(async () => {
await this.state.extension.emit('ready', {}, {
@@ -178,7 +177,7 @@ class ExtensionService extends BaseService {
});
}
['__on_install.routes'](_, { app }) {
['__on_install.routes'] (_, { app }) {
if ( ! this.state ) debugger;
for ( const thing of this.state.expressThings_ ) {
if ( thing.type === 'endpoint' ) {

View File

@@ -626,7 +626,7 @@ class WebServerService extends BaseService {
const allowed_headers = [
"Origin", "X-Requested-With", "Content-Type", "Accept", "Authorization", "sentry-trace", "baggage",
"Depth", "Destination", "Overwrite", "If", "Lock-Token", "DAV", "stripe-signature",
"Depth", "Destination", "Overwrite", "If", "Lock-Token", "DAV"
];
// Request headers to allow

View File

@@ -43,25 +43,28 @@ const config = require('../../../config.js');
* @param {*} handler the handler for the router
* @returns {express.Router} the router
*/
module.exports = function eggspress(route, settings, handler) {
module.exports = function eggspress (route, settings, handler) {
const router = express.Router();
const mw = [];
const afterMW = [];
const _defaultJsonOptions = {};
if ( settings.jsonCanBeLarge ) {
_defaultJsonOptions.limit = '10mb';
}
const shouldJson = settings.json === undefined && settings.noReallyItsJson === undefined ? true :
!!(settings.json || settings.noReallyItsJson); // default true if unset, but allow explicit false
// These flags enable specific middleware.
if ( settings.abuse ) mw.push(require('../../../middleware/abuse')(settings.abuse));
if ( settings.verified ) mw.push(require('../../../middleware/verified'));
if ( shouldJson ){
mw.push(express.json({ ..._defaultJsonOptions, type: settings.json ? undefined : settings.noReallyItsJson ? '*/*' : (req) => req.headers['content-type'] === 'text/plain;actually=json' }));
};
if ( settings.json ) mw.push(express.json(_defaultJsonOptions));
// A hack so plain text is parsed as JSON in methods which need to be lower latency/avoid the cors roundtrip
if ( settings.noReallyItsJson ) mw.push(express.json({ ..._defaultJsonOptions, type: '*/*' }));
mw.push(express.json({
..._defaultJsonOptions,
type: (req) => req.headers['content-type'] === "text/plain;actually=json",
}));
if ( settings.auth ) mw.push(require('../../../middleware/auth'));
if ( settings.auth2 ) mw.push(require('../../../middleware/auth2'));
@@ -94,8 +97,8 @@ module.exports = function eggspress(route, settings, handler) {
} catch (e) {
return res.status(400).send({
error: {
message: `Invalid JSON in multipart field ${key}`,
},
message: `Invalid JSON in multipart field ${key}`
}
});
}
next();
@@ -176,11 +179,9 @@ module.exports = function eggspress(route, settings, handler) {
});
}
if ( settings.mw ){
mw.push(...settings.mw);
}
if ( settings.mw ) mw.push(...settings.mw);
const errorHandledHandler = async function(req, res, next) {
const errorHandledHandler = async function (req, res, next) {
if ( settings.subdomain ) {
if ( subdomain(req) !== settings.subdomain ) {
return next();
@@ -200,7 +201,7 @@ module.exports = function eggspress(route, settings, handler) {
} else await handler(req, res, next);
} catch (e) {
if ( config.env === 'dev' ) {
if ( ! (e instanceof APIError) ) {
if (! (e instanceof APIError)) {
// Any non-APIError indicates an unhandled error (i.e. a bug) from the backend.
// We add a dedicated branch to facilitate debugging.
console.error(e);
@@ -209,57 +210,57 @@ module.exports = function eggspress(route, settings, handler) {
api_error_handler(e, req, res, next);
}
};
if ( settings.allowedMethods.includes('GET') ) {
if (settings.allowedMethods.includes('GET')) {
router.get(route, ...mw, errorHandledHandler, ...afterMW);
}
if ( settings.allowedMethods.includes('HEAD') ) {
if (settings.allowedMethods.includes('HEAD')) {
router.head(route, ...mw, errorHandledHandler, ...afterMW);
}
if ( settings.allowedMethods.includes('POST') ) {
if (settings.allowedMethods.includes('POST')) {
router.post(route, ...mw, errorHandledHandler, ...afterMW);
}
if ( settings.allowedMethods.includes('PUT') ) {
if (settings.allowedMethods.includes('PUT')) {
router.put(route, ...mw, errorHandledHandler, ...afterMW);
}
if ( settings.allowedMethods.includes('DELETE') ) {
if (settings.allowedMethods.includes('DELETE')) {
router.delete(route, ...mw, errorHandledHandler, ...afterMW);
}
if ( settings.allowedMethods.includes('PROPFIND') ) {
if (settings.allowedMethods.includes('PROPFIND')) {
router.propfind(route, ...mw, errorHandledHandler, ...afterMW);
}
if ( settings.allowedMethods.includes('PROPPATCH') ) {
if (settings.allowedMethods.includes('PROPPATCH')) {
router.proppatch(route, ...mw, errorHandledHandler, ...afterMW);
}
if ( settings.allowedMethods.includes('MKCOL') ) {
if (settings.allowedMethods.includes('MKCOL')) {
router.mkcol(route, ...mw, errorHandledHandler, ...afterMW);
}
if ( settings.allowedMethods.includes('COPY') ) {
if (settings.allowedMethods.includes('COPY')) {
router.copy(route, ...mw, errorHandledHandler, ...afterMW);
}
if ( settings.allowedMethods.includes('MOVE') ) {
if (settings.allowedMethods.includes('MOVE')) {
router.move(route, ...mw, errorHandledHandler, ...afterMW);
}
if ( settings.allowedMethods.includes('LOCK') ) {
if (settings.allowedMethods.includes('LOCK')) {
router.lock(route, ...mw, errorHandledHandler, ...afterMW);
}
if ( settings.allowedMethods.includes('UNLOCK') ) {
if (settings.allowedMethods.includes('UNLOCK')) {
router.unlock(route, ...mw, errorHandledHandler, ...afterMW);
}
if ( settings.allowedMethods.includes('OPTIONS') ) {
if (settings.allowedMethods.includes('OPTIONS')) {
router.options(route, ...mw, errorHandledHandler, ...afterMW);
}
return router;
};
}

View File

@@ -16,22 +16,22 @@
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
'use strict';
const { get_taskbar_items, send_email_verification_code, send_email_verification_token, username_exists, invalidate_cached_user_by_id, get_user } = require('../helpers');
"use strict"
const {get_taskbar_items, send_email_verification_code, send_email_verification_token, username_exists, invalidate_cached_user_by_id, get_user } = require('../helpers');
const config = require('../config');
const eggspress = require('../api/eggspress');
const { Context } = require('../util/context');
const { DB_WRITE } = require('../services/database/consts');
const { generate_identifier } = require('../util/identifier');
const { is_temp_users_disabled: lazy_temp_users,
is_user_signup_disabled: lazy_user_signup } = require('../helpers');
const { is_temp_users_disabled: lazy_temp_users,
is_user_signup_disabled: lazy_user_signup } = require("../helpers")
const { requireCaptcha } = require('../modules/captcha/middleware/captcha-middleware');
async function generate_random_username() {
async function generate_random_username () {
let username;
do {
username = generate_identifier();
} while ( await username_exists(username) );
} while (await username_exists(username));
return username;
}
@@ -46,16 +46,14 @@ module.exports = eggspress(['/signup'], {
no_bots: true,
// puter_origin: false,
shadow_ban_responder: (req, res) => {
res.status(400).send('email username mismatch; please provide a password');
},
res.status(400).send(`email username mismatch; please provide a password`);
}
},
mw: [requireCaptcha({ strictMode: true, eventType: 'signup' })], // Conditionally require captcha for signup
}, async (req, res, next) => {
// either api. subdomain or no subdomain
if ( require('../helpers').subdomain(req) !== 'api' && require('../helpers').subdomain(req) !== '' )
{
if(require('../helpers').subdomain(req) !== 'api' && require('../helpers').subdomain(req) !== '')
next();
}
const svc_edgeRateLimit = req.services.get('edge-rate-limit');
if ( ! svc_edgeRateLimit.check('signup') ) {
@@ -64,32 +62,31 @@ module.exports = eggspress(['/signup'], {
// modules
const db = req.services.get('database').get(DB_WRITE, 'auth');
const bcrypt = require('bcrypt');
const bcrypt = require('bcrypt')
const { v4: uuidv4 } = require('uuid');
const jwt = require('jsonwebtoken');
const validator = require('validator');
const jwt = require('jsonwebtoken')
const validator = require('validator')
let uuid_user;
const svc_auth = Context.get('services').get('auth');
const svc_authAudit = Context.get('services').get('auth-audit');
svc_authAudit.record({
requester: Context.get('requester'),
action: req.body.is_temp ? 'signup:temp' : 'signup:real',
action: req.body.is_temp ? `signup:temp` : `signup:real`,
body: req.body,
});
// check bot trap, if `p102xyzname` is anything but an empty string it means
// that a bot has filled the form
// doesn't apply to temp users
if ( !req.body.is_temp && req.body.p102xyzname !== '' )
{
if(!req.body.is_temp && req.body.p102xyzname !== '')
return res.send();
}
// cloudflare turnstile validation
//
// ref: https://developers.cloudflare.com/turnstile/get-started/server-side-validation/
if ( config.services?.['cloudflare-turnstile']?.enabled ) {
if (config.services?.['cloudflare-turnstile']?.enabled) {
const formData = new FormData();
formData.append('secret', config.services?.['cloudflare-turnstile']?.secret_key);
formData.append('response', req.body['cf-turnstile-response']);
@@ -97,14 +94,12 @@ module.exports = eggspress(['/signup'], {
const response = await fetch('https://challenges.cloudflare.com/turnstile/v0/siteverify', {
method: 'POST',
body: formData,
body: formData
});
const result = await response.json();
if ( !result.success )
{
if (!result.success)
return res.status(400).send('captcha verification failed');
}
}
// send event
@@ -117,7 +112,7 @@ module.exports = eggspress(['/signup'], {
};
const svc_event = Context.get('services').get('event');
await svc_event.emit('puter.signup', event);
await svc_event.emit('puter.signup', event)
if ( ! event.allow ) {
return res.status(400).send(event.error ?? 'You are not allowed to sign up.');
@@ -125,7 +120,9 @@ module.exports = eggspress(['/signup'], {
// check if user is already logged in
if ( req.body.is_temp && req.cookies[config.cookie_name] ) {
const { user, token } = await svc_auth.check_session(req.cookies[config.cookie_name]);
const { user, token } = await svc_auth.check_session(
req.cookies[config.cookie_name]
);
res.cookie(config.cookie_name, token, {
sameSite: 'none',
secure: true,
@@ -144,7 +141,7 @@ module.exports = eggspress(['/signup'], {
requires_email_confirmation: user.requires_email_confirmation,
is_temp: (user.password === null && user.email === null),
taskbar_items: await get_taskbar_items(user),
},
}
});
}
}
@@ -152,19 +149,19 @@ module.exports = eggspress(['/signup'], {
const is_temp_users_disabled = await lazy_temp_users();
const is_user_signup_disabled = await lazy_user_signup();
if ( is_temp_users_disabled && is_user_signup_disabled ) {
if (is_temp_users_disabled && is_user_signup_disabled) {
return res.status(403).send('User signup and Temporary users are disabled.');
}
if ( !req.body.is_temp && is_user_signup_disabled ) {
if (!req.body.is_temp && is_user_signup_disabled) {
return res.status(403).send('User signup is disabled.');
}
}
if ( req.body.is_temp && is_temp_users_disabled ) {
if (req.body.is_temp && is_temp_users_disabled) {
return res.status(403).send('Temporary users are disabled.');
}
if ( req.body.is_temp && event.no_temp_user ) {
if (req.body.is_temp && event.no_temp_user) {
return res.status(403).send('You must login or signup.');
}
@@ -172,101 +169,74 @@ module.exports = eggspress(['/signup'], {
req.body.username = req.body.username ?? await generate_random_username();
req.body.email = req.body.email ?? req.body.username + '@gmail.com';
req.body.password = req.body.password ?? 'sadasdfasdfsadfsa';
// send_confirmation_code
req.body.send_confirmation_code = req.body.send_confirmation_code ?? true;
// username is required
if ( !req.body.username )
{
return res.status(400).send('Username is required');
}
if(!req.body.username)
return res.status(400).send('Username is required')
// username must be a string
else if ( typeof req.body.username !== 'string' )
{
return res.status(400).send('username must be a string.');
}
else if (typeof req.body.username !== 'string')
return res.status(400).send('username must be a string.')
// check if username is valid
else if ( !req.body.username.match(config.username_regex) )
{
return res.status(400).send('Username can only contain letters, numbers and underscore (_).');
}
else if(!req.body.username.match(config.username_regex))
return res.status(400).send('Username can only contain letters, numbers and underscore (_).')
// check if username is of proper length
else if ( req.body.username.length > config.username_max_length )
{
return res.status(400).send(`Username cannot be longer than ${config.username_max_length} characters.`);
}
else if(req.body.username.length > config.username_max_length)
return res.status(400).send(`Username cannot be longer than ${config.username_max_length} characters.`)
// check if username matches any reserved words
else if ( config.reserved_words.includes(req.body.username) )
{
return res.status(400).send({ message: 'This username is not available.' });
}
else if(config.reserved_words.includes(req.body.username))
return res.status(400).send({message: 'This username is not available.'});
// TODO: DRY: change_email.js
else if ( !req.body.is_temp && !req.body.email )
{
else if(!req.body.is_temp && !req.body.email)
return res.status(400).send('Email is required');
}
// email, if present, must be a string
else if ( req.body.email && typeof req.body.email !== 'string' )
{
return res.status(400).send('email must be a string.');
}
else if (req.body.email && typeof req.body.email !== 'string')
return res.status(400).send('email must be a string.')
// if email is present, validate it
else if ( !req.body.is_temp && !validator.isEmail(req.body.email) )
{
return res.status(400).send('Please enter a valid email address.');
}
else if ( !req.body.is_temp && !req.body.password )
{
else if(!req.body.is_temp && !validator.isEmail(req.body.email))
return res.status(400).send('Please enter a valid email address.')
else if(!req.body.is_temp && !req.body.password)
return res.status(400).send('Password is required');
}
// password, if present, must be a string
else if ( req.body.password && typeof req.body.password !== 'string' )
{
return res.status(400).send('password must be a string.');
}
else if ( !req.body.is_temp && req.body.password.length < config.min_pass_length )
{
else if (req.body.password && typeof req.body.password !== 'string')
return res.status(400).send('password must be a string.')
else if(!req.body.is_temp && req.body.password.length < config.min_pass_length)
return res.status(400).send(`Password must be at least ${config.min_pass_length} characters long.`);
}
const svc_cleanEmail = req.services.get('clean-email');
const clean_email = svc_cleanEmail.clean(req.body.email);
if ( !req.body.is_temp && ! await svc_cleanEmail.validate(clean_email) ) {
if (!req.body.is_temp && ! await svc_cleanEmail.validate(clean_email) ) {
return res.status(400).send('This email does not seem to be valid.');
}
// duplicate username check
if ( await username_exists(req.body.username) )
{
if(await username_exists(req.body.username))
return res.status(400).send('This username already exists in our database. Please use another one.');
}
// Email check is here :: Add condition for email_confirmed=1
// duplicate email check (pseudo-users don't count)
let rows2 = await db.read(`SELECT EXISTS(
let rows2 = await db.read(
`SELECT EXISTS(
SELECT 1 FROM user WHERE (email=? OR clean_email=?) AND email_confirmed=1 AND password IS NOT NULL
) AS email_exists`, [req.body.email, clean_email]);
if ( rows2[0].email_exists )
{
if(rows2[0].email_exists)
return res.status(400).send('This email already exists in our database. Please use another one.');
}
// get pseudo user, if exists
let pseudo_user = await db.read('SELECT * FROM user WHERE email = ? AND password IS NULL', [req.body.email]);
let pseudo_user = await db.read(`SELECT * FROM user WHERE email = ? AND password IS NULL`, [req.body.email]);
pseudo_user = pseudo_user[0];
// get uuid user, if exists
if ( req.body.uuid ) {
uuid_user = await db.read('SELECT * FROM user WHERE uuid = ? LIMIT 1', [req.body.uuid]);
if(req.body.uuid){
uuid_user = await db.read(`SELECT * FROM user WHERE uuid = ? LIMIT 1`, [req.body.uuid]);
uuid_user = uuid_user[0];
}
// email confirmation is required by default unless:
// Pseudo user converting and matching uuid is provided
let email_confirmation_required = 1;
if ( pseudo_user && uuid_user && pseudo_user.id === uuid_user.id )
{
if(pseudo_user && uuid_user && pseudo_user.id === uuid_user.id)
email_confirmation_required = 0;
}
// -----------------------------------
// Get referral user
@@ -295,116 +265,95 @@ module.exports = eggspress(['/signup'], {
server: config.server_id,
};
if ( pseudo_user === undefined ) {
insert_res = await db.write(`INSERT INTO user
if(pseudo_user === undefined){
insert_res = await db.write(
`INSERT INTO user
(
username,
email,
clean_email,
password,
uuid,
referrer,
email_confirm_code,
email_confirm_token,
free_storage,
referred_by,
audit_metadata,
signup_ip,
signup_ip_forwarded,
signup_user_agent,
signup_origin,
signup_server
username, email, clean_email, password, uuid, referrer,
email_confirm_code, email_confirm_token, free_storage,
referred_by, audit_metadata, signup_ip, signup_ip_forwarded,
signup_user_agent, signup_origin, signup_server
)
VALUES
(?,
?,
?,
?,
?,
?,
?,
?,
?,
?,
?,
?,
?,
?,
?,
?)`,
[
// username
req.body.username,
// email
req.body.is_temp ? null : req.body.email,
// normalized email
req.body.is_temp ? null : clean_email,
// password
req.body.is_temp ? null : await bcrypt.hash(req.body.password, 8),
// uuid
user_uuid,
// referrer
req.body.referrer ?? null,
// email_confirm_code
'' + email_confirm_code,
// email_confirm_token
email_confirm_token,
// free_storage
config.storage_capacity,
// referred_by
referred_by_user ? referred_by_user.id : null,
// audit_metadata
JSON.stringify(audit_metadata),
// signup_ip
req.connection.remoteAddress ?? null,
// signup_ip_fwd
req.headers['x-forwarded-for'] ?? null,
// signup_user_agent
req.headers['user-agent'] ?? null,
// signup_origin
req.headers['origin'] ?? null,
// signup_server
config.server_id ?? null,
]);
(?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
[
// username
req.body.username,
// email
req.body.is_temp ? null : req.body.email,
// normalized email
req.body.is_temp ? null : clean_email,
// password
req.body.is_temp ? null : await bcrypt.hash(req.body.password, 8),
// uuid
user_uuid,
// referrer
req.body.referrer ?? null,
// email_confirm_code
'' + email_confirm_code,
// email_confirm_token
email_confirm_token,
// free_storage
config.storage_capacity,
// referred_by
referred_by_user ? referred_by_user.id : null,
// audit_metadata
JSON.stringify(audit_metadata),
// signup_ip
req.connection.remoteAddress ?? null,
// signup_ip_fwd
req.headers['x-forwarded-for'] ?? null,
// signup_user_agent
req.headers['user-agent'] ?? null,
// signup_origin
req.headers['origin'] ?? null,
// signup_server
config.server_id ?? null,
]
);
// record activity
db.write('UPDATE `user` SET `last_activity_ts` = now() WHERE id=? LIMIT 1',
[insert_res.insertId]);
db.write(
'UPDATE `user` SET `last_activity_ts` = now() WHERE id=? LIMIT 1',
[insert_res.insertId]
);
// TODO: cache group id
const svc_group = req.services.get('group');
await svc_group.add_users({
uid: req.body.is_temp ?
config.default_temp_group : config.default_user_group,
users: [req.body.username],
users: [req.body.username]
});
}
// -----------------------------------
// Pseudo User converting
// -----------------------------------
else {
insert_res = await db.write(`UPDATE user SET
else{
insert_res = await db.write(
`UPDATE user SET
username = ?, password = ?, uuid = ?, email_confirm_code = ?, email_confirm_token = ?, email_confirmed = ?, requires_email_confirmation = 1,
referred_by = ?
WHERE id = ?`,
[
// username
req.body.username,
// password
await bcrypt.hash(req.body.password, 8),
// uuid
user_uuid,
// email_confirm_code
'' + email_confirm_code,
// email_confirm_token
email_confirm_token,
// email_confirmed
!email_confirmation_required,
// id
pseudo_user.id,
// referred_by
referred_by_user ? referred_by_user.id : null,
]);
[
// username
req.body.username,
// password
await bcrypt.hash(req.body.password, 8),
// uuid
user_uuid,
// email_confirm_code
'' + email_confirm_code,
// email_confirm_token
email_confirm_token,
// email_confirmed
!email_confirmation_required,
// id
pseudo_user.id,
// referred_by
referred_by_user ? referred_by_user.id : null,
]
);
// TODO: cache group ids
const svc_group = req.services.get('group');
@@ -414,7 +363,7 @@ module.exports = eggspress(['/signup'], {
});
await svc_group.add_users({
uid: config.default_user_group,
users: [req.body.username],
users: [req.body.username]
});
// record activity
@@ -426,8 +375,10 @@ module.exports = eggspress(['/signup'], {
// todo if pseudo user, assign directly no need to do another DB lookup
const user_id = (pseudo_user === undefined) ? insert_res.insertId : pseudo_user.id;
const [user] = await db.pread('SELECT * FROM `user` WHERE `id` = ? LIMIT 1',
[user_id]);
const [user] = await db.pread(
'SELECT * FROM `user` WHERE `id` = ? LIMIT 1',
[user_id]
);
// create token for login
const { token } = await svc_auth.create_session_token(user, {
@@ -439,15 +390,11 @@ module.exports = eggspress(['/signup'], {
// email confirmation
//-------------------------------------------------------------
// Email confirmation from signup is sent here
if ( (!req.body.is_temp && email_confirmation_required) || user.requires_email_confirmation ) {
if ( req.body.send_confirmation_code || user.requires_email_confirmation )
{
if((!req.body.is_temp && email_confirmation_required) || user.requires_email_confirmation){
if(req.body.send_confirmation_code || user.requires_email_confirmation)
send_email_verification_code(email_confirm_code, user.email);
}
else
{
send_email_verification_token(user.email_confirm_token, user.email, user.uuid);
}
}
//-------------------------------------------------------------
@@ -473,7 +420,7 @@ module.exports = eggspress(['/signup'], {
});
// add to mailchimp
if ( !req.body.is_temp ) {
if(!req.body.is_temp){
const svc_event = Context.get('services').get('event');
svc_event.emit('user.save_account', { user });
}
@@ -481,7 +428,7 @@ module.exports = eggspress(['/signup'], {
// return results
return res.send({
token: token,
user: {
user:{
username: user.username,
uuid: user.uuid,
email: user.email,
@@ -490,6 +437,6 @@ module.exports = eggspress(['/signup'], {
is_temp: (user.password === null && user.email === null),
taskbar_items: await get_taskbar_items(user),
referral_code,
},
});
}
})
});

View File

@@ -17,20 +17,20 @@
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
const { Actor } = require('./auth/Actor');
const BaseService = require('./BaseService');
const { DB_READ } = require('./database/consts');
const { Actor } = require("./auth/Actor");
const BaseService = require("./BaseService");
const { DB_READ } = require("./database/consts");
/**
* Get user by one of a variety of identifying properties.
*
*
* Pass `cached: false` to options to force a database read.
* Pass `force: true` to options to force a primary database read.
*
*
* This provides the functionality of `get_user` (helpers.js)
* as a service so that other services can register identifying
* properties for caching.
*
*
* The original `get_user` function now uses this service.
*/
class GetUserService extends BaseService {
@@ -38,7 +38,7 @@ class GetUserService extends BaseService {
* Constructor for GetUserService.
* Initializes the set of identifying properties used to retrieve user data.
*/
_construct() {
_construct () {
this.id_properties = new Set();
this.id_properties.add('username');
@@ -52,35 +52,35 @@ class GetUserService extends BaseService {
* Initializes the GetUserService instance.
* This method prepares any necessary internal structures or states.
* It is called automatically upon instantiation of the service.
*
*
* @returns {Promise<void>} A promise that resolves when the initialization is complete.
*/
async _init() {
async _init () {
}
/**
* Retrieves a user object based on the provided options.
*
*
* This method queries the user from cache or database,
* depending on the caching options provided. If the user
* is found, it also calls the 'whoami' service to enrich
* depending on the caching options provided. If the user
* is found, it also calls the 'whoami' service to enrich
* the user details before returning.
*
*
* @param {Object} options - The options for retrieving the user.
* @param {boolean} [options.cached=true] - Indicates if caching should be used.
* @param {boolean} [options.force=false] - Forces a read from the database regardless of cache.
* @returns {Promise<Object|null>} The user object if found, else null.
*/
async get_user(options) {
async get_user (options) {
const user = await this.get_user_(options);
if ( ! user ) return null;
const svc_whoami = this.services.get('whoami');
await svc_whoami.get_details({ user }, user);
return user;
}
async refresh_actor(actor) {
async refresh_actor (actor) {
if ( actor.type.user ) {
actor.type.user = await this.get_user({
username: actor.type.user.username,
@@ -90,7 +90,7 @@ class GetUserService extends BaseService {
return actor;
}
async get_user_(options) {
async get_user_ (options) {
const services = this.services;
/** @type BaseDatabaseAccessService */
@@ -135,19 +135,13 @@ class GetUserService extends BaseService {
kv.set(`users:${prop}:${user[prop]}`, user);
}
}
} catch ( e ) {
} catch (e) {
console.error(e);
}
if ( user.metadata && typeof user.metadata === 'string' ){
user.metadata = JSON.parse(user.metadata);
} else if ( !user.metadata ){
user.metadata = {};
}
return user;
}
register_id_property(prop) {
register_id_property (prop) {
this.id_properties.add(prop);
}
}

View File

@@ -1,5 +1,5 @@
import { REGISTERED_USER_FREE } from './registeredUserFreePolicy';
import { TEMP_USER_FREE } from './tempUserFreePolicy';
import { REGISTERED_USER_FREE } from "./registeredUserFreePolicy";
import { TEMP_USER_FREE } from "./tempUserFreePolicy";
export const SUB_POLICIES = [
TEMP_USER_FREE,

View File

@@ -4,4 +4,4 @@ export const REGISTERED_USER_FREE = {
id: 'user_free',
monthUsageAllowance: toMicroCents(0.50),
monthlyStorageAllowance: 100 * 1024 * 1024, // 100MiB
} as const;
};

View File

@@ -4,4 +4,4 @@ export const TEMP_USER_FREE = {
id: 'temp_free',
monthUsageAllowance: toMicroCents(0.25),
monthlyStorageAllowance: 100 * 1024 * 1024, // 100MiB
} as const;
};

View File

@@ -1,8 +0,0 @@
import { SUB_POLICIES } from './MeteringService/subPolicies';
export interface IUser { uuid: string,
username: string,
email: string,
subscription?: (typeof SUB_POLICIES)[number]['id'],
metadata?: Record<string, unknown> & { hasDevAccountAccess?: boolean }
}

View File

@@ -1,12 +0,0 @@
import type { BaseService } from './BaseService';
import type { IUser } from './User';
export interface IInsertResult {
insertId: number;
}
export class UserService extends BaseService {
get_system_dir(): unknown;
generate_default_fsentries(args: { user: IUser }): Promise<void>;
updateUserMetadata(user: IUser, updatedMetadata: Record<string, unknown>): Promise<void>;
}

View File

@@ -1,47 +1,51 @@
/*
* Copyright (C) 2024-present Puter Technologies Inc.
*
*
* This file is part of Puter.
*
*
* Puter is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published
* by the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
const { RootNodeSelector, NodeChildSelector } = require('../filesystem/node/selectors');
const { invalidate_cached_user } = require('../helpers');
const BaseService = require('./BaseService');
const { DB_WRITE } = require('./database/consts');
const { RootNodeSelector, NodeChildSelector } = require("../filesystem/node/selectors");
const { invalidate_cached_user } = require("../helpers");
const BaseService = require("./BaseService");
const { DB_WRITE } = require("./database/consts");
class UserService extends BaseService {
static MODULES = {
uuidv4: require('uuid').v4,
};
async _init() {
async _init () {
this.db = this.services.get('database').get(DB_WRITE, 'user-service');
this.dir_system = null;
}
async ['__on_filesystem.ready']() {
async ['__on_filesystem.ready'] () {
const svc_fs = this.services.get('filesystem');
// Ensure system user has a home directory
const dir_system = await svc_fs.node(new NodeChildSelector(new RootNodeSelector(),
'system'));
const dir_system = await svc_fs.node(
new NodeChildSelector(
new RootNodeSelector(),
'system'
)
);
if ( ! await dir_system.exists() ) {
const svc_getUser = this.services.get('get-user');
await this.generate_default_fsentries({
user: await svc_getUser.get_user({ username: 'system' }),
user: await svc_getUser.get_user({ username: 'system' })
});
}
@@ -50,15 +54,15 @@ class UserService extends BaseService {
this.services.emit('user.system-user-ready');
}
get_system_dir() {
get_system_dir () {
return this.dir_system;
}
// used to be called: generate_system_fsentries
async generate_default_fsentries({ user }) {
async generate_default_fsentries ({ user }) {
this.log.noticeme('YES THIS WAS USED');
// Note: The comment below is outdated as we now do parallel writes for
// all filesystem operations. However, there may still be some
// performance hit so this requires further investigation.
@@ -70,7 +74,7 @@ class UserService extends BaseService {
// by combining as many queries as we can into one and avoiding multiple back-and-forth
// with the DB server, we can speed this process up significantly.
const ts = Date.now() / 1000;
const ts = Date.now()/1000;
// Generate UUIDs for all the default folders and files
const uuidv4 = this.modules.uuidv4;
@@ -84,7 +88,8 @@ class UserService extends BaseService {
let videos_uuid = uuidv4();
let public_uuid = uuidv4();
const insert_res = await this.db.write(`INSERT INTO fsentries
const insert_res = await this.db.write(
`INSERT INTO fsentries
(uuid, parent_uid, user_id, name, path, is_dir, created, modified, immutable) VALUES
( ?, ?, ?, ?, ?, true, ?, ?, true),
( ?, ?, ?, ?, ?, true, ?, ?, true),
@@ -95,24 +100,25 @@ class UserService extends BaseService {
( ?, ?, ?, ?, ?, true, ?, ?, true),
( ?, ?, ?, ?, ?, true, ?, ?, true)
`,
[
// Home
home_uuid, null, user.id, user.username, `/${user.username}`, ts, ts,
// Trash
trash_uuid, home_uuid, user.id, 'Trash', `/${user.username}/Trash`, ts, ts,
// AppData
appdata_uuid, home_uuid, user.id, 'AppData', `/${user.username}/AppData`, ts, ts,
// Desktop
desktop_uuid, home_uuid, user.id, 'Desktop', `/${user.username}/Desktop`, ts, ts,
// Documents
documents_uuid, home_uuid, user.id, 'Documents', `/${user.username}/Documents`, ts, ts,
// Pictures
pictures_uuid, home_uuid, user.id, 'Pictures', `/${user.username}/Pictures`, ts, ts,
// Videos
videos_uuid, home_uuid, user.id, 'Videos', `/${user.username}/Videos`, ts, ts,
// Public
public_uuid, home_uuid, user.id, 'Public', `/${user.username}/Public`, ts, ts,
]);
[
// Home
home_uuid, null, user.id, user.username, `/${user.username}`, ts, ts,
// Trash
trash_uuid, home_uuid, user.id, 'Trash', `/${user.username}/Trash`, ts, ts,
// AppData
appdata_uuid, home_uuid, user.id, 'AppData', `/${user.username}/AppData`, ts, ts,
// Desktop
desktop_uuid, home_uuid, user.id, 'Desktop', `/${user.username}/Desktop`, ts, ts,
// Documents
documents_uuid, home_uuid, user.id, 'Documents', `/${user.username}/Documents`, ts, ts,
// Pictures
pictures_uuid, home_uuid, user.id, 'Pictures', `/${user.username}/Pictures`, ts, ts,
// Videos
videos_uuid, home_uuid, user.id, 'Videos', `/${user.username}/Videos`, ts, ts,
// Public
public_uuid, home_uuid, user.id, 'Public', `/${user.username}/Public`, ts, ts,
]
);
// https://stackoverflow.com/a/50103616
let trash_id = insert_res.insertId;
@@ -129,29 +135,20 @@ class UserService extends BaseService {
// TODO: pass to IIAFE manager to avoid unhandled promise rejection
// (IIAFE manager doesn't exist yet, hence this is a TODO)
this.db.write(`UPDATE user SET
this.db.write(
`UPDATE user SET
trash_uuid=?, appdata_uuid=?, desktop_uuid=?, documents_uuid=?, pictures_uuid=?, videos_uuid=?, public_uuid=?,
trash_id=?, appdata_id=?, desktop_id=?, documents_id=?, pictures_id=?, videos_id=?, public_id=?
WHERE id=?`,
[
trash_uuid, appdata_uuid, desktop_uuid, documents_uuid, pictures_uuid, videos_uuid, public_uuid,
trash_id, appdata_id, desktop_id, documents_id, pictures_id, videos_id, public_id,
user.id,
]);
[
trash_uuid, appdata_uuid, desktop_uuid, documents_uuid, pictures_uuid, videos_uuid, public_uuid,
trash_id, appdata_id, desktop_id, documents_id, pictures_id, videos_id, public_id,
user.id
]
);
invalidate_cached_user(user);
}
async updateUserMetadata(user, updatedMetadata){
let metadata = user.metadata;
if ( !Object.keys(metadata).length ){
metadata = updatedMetadata;
} else {
metadata = { ...metadata, ...updatedMetadata };
}
await this.db.write('UPDATE user SET metadata=? WHERE id=?', [metadata]);
}
}
module.exports = {

View File

@@ -1,17 +1,15 @@
import { IUser } from '../User';
export class SystemActorType {
get uid(): string;
get_related_type(type_class: unknown): SystemActorType;
get_related_type(type_class: any): SystemActorType;
}
export class Actor {
type: {
app: { uid: string }
user: IUser
};
user: { uuid: string, username: string, email: string, subscription?: (typeof SUB_POLICIES)[keyof typeof SUB_POLICIES]['id'] }
}
get uid(): string;
clone(): Actor;
static get_system_actor(): Actor;
static adapt(actor?: Actor): Actor;
static adapt(actor?: any): Actor;
}

View File

@@ -1,29 +0,0 @@
import { BaseService } from "../BaseService";
export type DBMode = "DB_WRITE" | "DB_READ";
export interface IBaseDatabaseAccessService {
get(): this;
read(query: string, params?: any[]): Promise<any>;
tryHardRead(query: string, params?: any[]): Promise<any>;
requireRead(query: string, params?: any[]): Promise<any>;
pread(query: string, params?: any[]): Promise<any>;
write(query: string, params?: any[]): Promise<any>;
insert(table_name: string, data: Record<string, any>): Promise<any>;
batch_write(statements: string[]): any;
}
export class BaseDatabaseAccessService extends BaseService implements IBaseDatabaseAccessService {
static DB_WRITE: DBMode;
static DB_READ: DBMode;
case<T>(choices: Record<string, T>): T;
get(): this;
read(query: string, params?: any[]): Promise<any>;
tryHardRead(query: string, params?: any[]): Promise<any>;
requireRead(query: string, params?: any[]): Promise<any>;
pread(query: string, params?: any[]): Promise<any>;
write(query: string, params?: any[]): Promise<any>;
insert(table_name: string, data: Record<string, any>): Promise<any>;
batch_write(statements: string[]): any;
_gen_insert_sql(table_name: string, data: Record<string, any>): string;
}

View File

@@ -166,9 +166,6 @@ class SqliteDatabaseAccessService extends BaseDatabaseAccessService {
[35, [
'0039_add-expireAt-to-kv-store.sql',
]],
[36, [
'0040_add_user_metadata.sql',
]],
];
// Database upgrade logic
@@ -223,7 +220,7 @@ class SqliteDatabaseAccessService extends BaseDatabaseAccessService {
const stmt = stmts[i] + ';';
try {
this.db.exec(stmt);
} catch ( e ) {
} catch( e ) {
throw new CompositeError(`failed to apply: ${basename} at line ${i}`, e);
}
}
@@ -234,7 +231,7 @@ class SqliteDatabaseAccessService extends BaseDatabaseAccessService {
await this.run_js_migration_({
filename, contents,
});
} catch ( e ) {
} catch( e ) {
throw new CompositeError(`failed to apply: ${basename}`, e);
}
break;
@@ -398,7 +395,7 @@ class SqliteDatabaseAccessService extends BaseDatabaseAccessService {
const fs = require('fs');
const contents = fs.readFileSync(filename, 'utf8');
this.db.exec(contents);
} catch ( err ) {
} catch( err ) {
log.error(err.message);
}
},
@@ -411,7 +408,7 @@ class SqliteDatabaseAccessService extends BaseDatabaseAccessService {
const [query] = args;
const rows = this._read(query, []);
log.log(rows);
} catch ( err ) {
} catch( err ) {
log.error(err.message);
}
},

View File

@@ -1 +0,0 @@
ALTER TABLE `user` ADD COLUMN `metadata` JSON DEFAULT '{}';

View File

@@ -16,33 +16,34 @@
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
const eggspress = require('../api/eggspress');
const eggspress = require("../api/eggspress");
const Endpoint = function Endpoint(spec, handler) {
const Endpoint = function Endpoint (spec, handler) {
return {
attach(route) {
attach (route) {
const eggspress_options = {
allowedMethods: spec.methods ?? ['GET'],
...(spec.subdomain ? { subdomain: spec.subdomain } : {}),
...(spec.parameters ? { parameters: spec.parameters } : {}),
...(spec.alias ? { alias: spec.alias } : {}),
...(spec.mw ? { mw: spec.mw } : {}),
...spec.otherOpts,
};
const eggspress_router = eggspress(spec.route,
eggspress_options,
handler ?? spec.handler);
const eggspress_router = eggspress(
spec.route,
eggspress_options,
handler ?? spec.handler,
);
route.use(eggspress_router);
},
but(newSpec) {
but (newSpec) {
// TODO: add merge with '$' behaviors (like config has)
return Endpoint({
...spec,
...newSpec,
});
},
}
};
};
}
module.exports = {
Endpoint,

View File

@@ -31,53 +31,45 @@
<script src="https://cdn.jsdelivr.net/npm/@yaireo/tagify/dist/tagify.polyfills.min.js"></script>
<link href="https://cdn.jsdelivr.net/npm/@yaireo/tagify/dist/tagify.css" rel="stylesheet" type="text/css" />
<style>
.social-link {
.social-link{
opacity: 0.7;
color: rgb(70, 78, 86);
}
.social-link:hover {
.social-link:hover{
opacity: 1;
}
.social-link svg {
.social-link svg{
width: 18px;
height: 18px;
margin-right: 10px;
}
.sidebar-nav-social {
.sidebar-nav-social{
margin-top: -10px;
}
.sidebar-nav-social li {
.sidebar-nav-social li{
display: inline;
padding: 0;
padding:0;
margin-left: 20px;
margin-right: -10px;
}
.sidebar-nav-social li a {
.sidebar-nav-social li a{
overflow: hidden;
display: inline-block;
}
tags {
tags{
min-width: 500px;
}
.analytics-card {
.analytics-card{
margin-top: 20px;
padding: 20px;
border-radius: 10px;
background-color: #f2f4f5eb;
width: 200px;
text-align: center;
float: left;
float:left;
margin-right: 23px;
}
.analytics-card h3 {
.analytics-card h3{
color: #838383;
font-weight: 500;
}
@@ -100,368 +92,245 @@
<!-- Footer -->
<div style="overflow: hidden; position: absolute; bottom: 0; width: 100%;">
<ul class="sidebar-nav">
<li class="no-hover" style="margin-left:0;"><a href="https://developer.puter.com/"
class="link-to-docs" target="_blank">Developer Documentation<img
src="img/external-link.svg"></a></li>
<li class="no-hover" style="margin-left:0;"><a href="https://developer.puter.com/" class="link-to-docs" target="_blank">Developer Documentation<img src="img/external-link.svg"></a></li>
</ul>
<ul class="sidebar-nav sidebar-nav-social">
<li class="no-hover"><a href="https://github.com/HeyPuter/puter" class="social-link"
target="_blank"><svg xmlns="http://www.w3.org/2000/svg" width="16" height="16"
fill="currentColor" class="bi bi-github" viewBox="0 0 16 16">
<path
d="M8 0C3.58 0 0 3.58 0 8c0 3.54 2.29 6.53 5.47 7.59.4.07.55-.17.55-.38 0-.19-.01-.82-.01-1.49-2.01.37-2.53-.49-2.69-.94-.09-.23-.48-.94-.82-1.13-.28-.15-.68-.52-.01-.53.63-.01 1.08.58 1.23.82.72 1.21 1.87.87 2.33.66.07-.52.28-.87.51-1.07-1.78-.2-3.64-.89-3.64-3.95 0-.87.31-1.59.82-2.15-.08-.2-.36-1.02.08-2.12 0 0 .67-.21 2.2.82.64-.18 1.32-.27 2-.27s1.36.09 2 .27c1.53-1.04 2.2-.82 2.2-.82.44 1.1.16 1.92.08 2.12.51.56.82 1.27.82 2.15 0 3.07-1.87 3.75-3.65 3.95.29.25.54.73.54 1.48 0 1.07-.01 1.93-.01 2.2 0 .21.15.46.55.38A8.01 8.01 0 0 0 16 8c0-4.42-3.58-8-8-8" />
</svg></a></li>
<li class="no-hover"><a href="https://dsc.gg/puter" class="social-link" target="_blank"><svg
xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="currentColor"
class="bi bi-discord" viewBox="0 0 16 16">
<path
d="M13.545 2.907a13.2 13.2 0 0 0-3.257-1.011.05.05 0 0 0-.052.025c-.141.25-.297.577-.406.833a12.2 12.2 0 0 0-3.658 0 8 8 0 0 0-.412-.833.05.05 0 0 0-.052-.025c-1.125.194-2.22.534-3.257 1.011a.04.04 0 0 0-.021.018C.356 6.024-.213 9.047.066 12.032q.003.022.021.037a13.3 13.3 0 0 0 3.995 2.02.05.05 0 0 0 .056-.019q.463-.63.818-1.329a.05.05 0 0 0-.01-.059l-.018-.011a9 9 0 0 1-1.248-.595.05.05 0 0 1-.02-.066l.015-.019q.127-.095.248-.195a.05.05 0 0 1 .051-.007c2.619 1.196 5.454 1.196 8.041 0a.05.05 0 0 1 .053.007q.121.1.248.195a.05.05 0 0 1-.004.085 8 8 0 0 1-1.249.594.05.05 0 0 0-.03.03.05.05 0 0 0 .003.041c.24.465.515.909.817 1.329a.05.05 0 0 0 .056.019 13.2 13.2 0 0 0 4.001-2.02.05.05 0 0 0 .021-.037c.334-3.451-.559-6.449-2.366-9.106a.03.03 0 0 0-.02-.019m-8.198 7.307c-.789 0-1.438-.724-1.438-1.612s.637-1.613 1.438-1.613c.807 0 1.45.73 1.438 1.613 0 .888-.637 1.612-1.438 1.612m5.316 0c-.788 0-1.438-.724-1.438-1.612s.637-1.613 1.438-1.613c.807 0 1.451.73 1.438 1.613 0 .888-.631 1.612-1.438 1.612" />
</svg></a></li>
<li class="no-hover"><a href="https://x.com/HeyPuter" class="social-link" target="_blank"><svg
xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="currentColor"
class="bi bi-twitter-x" viewBox="0 0 16 16">
<path
d="M12.6.75h2.454l-5.36 6.142L16 15.25h-4.937l-3.867-5.07-4.425 5.07H.316l5.733-6.57L0 .75h5.063l3.495 4.633L12.601.75Zm-.86 13.028h1.36L4.323 2.145H2.865z" />
</svg></a></li>
<li class="no-hover"><a href="https://reddit.com/r/puter" class="social-link" target="_blank"><svg
xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="currentColor"
class="bi bi-reddit" viewBox="0 0 16 16">
<path
d="M6.167 8a.83.83 0 0 0-.83.83c0 .459.372.84.83.831a.831.831 0 0 0 0-1.661m1.843 3.647c.315 0 1.403-.038 1.976-.611a.23.23 0 0 0 0-.306.213.213 0 0 0-.306 0c-.353.363-1.126.487-1.67.487-.545 0-1.308-.124-1.671-.487a.213.213 0 0 0-.306 0 .213.213 0 0 0 0 .306c.564.563 1.652.61 1.977.61zm.992-2.807c0 .458.373.83.831.83s.83-.381.83-.83a.831.831 0 0 0-1.66 0z" />
<path
d="M16 8A8 8 0 1 1 0 8a8 8 0 0 1 16 0m-3.828-1.165c-.315 0-.602.124-.812.325-.801-.573-1.9-.945-3.121-.993l.534-2.501 1.738.372a.83.83 0 1 0 .83-.869.83.83 0 0 0-.744.468l-1.938-.41a.2.2 0 0 0-.153.028.2.2 0 0 0-.086.134l-.592 2.788c-1.24.038-2.358.41-3.17.992-.21-.2-.496-.324-.81-.324a1.163 1.163 0 0 0-.478 2.224q-.03.17-.029.353c0 1.795 2.091 3.256 4.669 3.256s4.668-1.451 4.668-3.256c0-.114-.01-.238-.029-.353.401-.181.688-.592.688-1.069 0-.65-.525-1.165-1.165-1.165" />
</svg></a></li>
<li class="no-hover"><a href="https://github.com/HeyPuter/puter" class="social-link" target="_blank"><svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="currentColor" class="bi bi-github" viewBox="0 0 16 16"> <path d="M8 0C3.58 0 0 3.58 0 8c0 3.54 2.29 6.53 5.47 7.59.4.07.55-.17.55-.38 0-.19-.01-.82-.01-1.49-2.01.37-2.53-.49-2.69-.94-.09-.23-.48-.94-.82-1.13-.28-.15-.68-.52-.01-.53.63-.01 1.08.58 1.23.82.72 1.21 1.87.87 2.33.66.07-.52.28-.87.51-1.07-1.78-.2-3.64-.89-3.64-3.95 0-.87.31-1.59.82-2.15-.08-.2-.36-1.02.08-2.12 0 0 .67-.21 2.2.82.64-.18 1.32-.27 2-.27s1.36.09 2 .27c1.53-1.04 2.2-.82 2.2-.82.44 1.1.16 1.92.08 2.12.51.56.82 1.27.82 2.15 0 3.07-1.87 3.75-3.65 3.95.29.25.54.73.54 1.48 0 1.07-.01 1.93-.01 2.2 0 .21.15.46.55.38A8.01 8.01 0 0 0 16 8c0-4.42-3.58-8-8-8"/> </svg></a></li>
<li class="no-hover"><a href="https://dsc.gg/puter" class="social-link" target="_blank"><svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="currentColor" class="bi bi-discord" viewBox="0 0 16 16"> <path d="M13.545 2.907a13.2 13.2 0 0 0-3.257-1.011.05.05 0 0 0-.052.025c-.141.25-.297.577-.406.833a12.2 12.2 0 0 0-3.658 0 8 8 0 0 0-.412-.833.05.05 0 0 0-.052-.025c-1.125.194-2.22.534-3.257 1.011a.04.04 0 0 0-.021.018C.356 6.024-.213 9.047.066 12.032q.003.022.021.037a13.3 13.3 0 0 0 3.995 2.02.05.05 0 0 0 .056-.019q.463-.63.818-1.329a.05.05 0 0 0-.01-.059l-.018-.011a9 9 0 0 1-1.248-.595.05.05 0 0 1-.02-.066l.015-.019q.127-.095.248-.195a.05.05 0 0 1 .051-.007c2.619 1.196 5.454 1.196 8.041 0a.05.05 0 0 1 .053.007q.121.1.248.195a.05.05 0 0 1-.004.085 8 8 0 0 1-1.249.594.05.05 0 0 0-.03.03.05.05 0 0 0 .003.041c.24.465.515.909.817 1.329a.05.05 0 0 0 .056.019 13.2 13.2 0 0 0 4.001-2.02.05.05 0 0 0 .021-.037c.334-3.451-.559-6.449-2.366-9.106a.03.03 0 0 0-.02-.019m-8.198 7.307c-.789 0-1.438-.724-1.438-1.612s.637-1.613 1.438-1.613c.807 0 1.45.73 1.438 1.613 0 .888-.637 1.612-1.438 1.612m5.316 0c-.788 0-1.438-.724-1.438-1.612s.637-1.613 1.438-1.613c.807 0 1.451.73 1.438 1.613 0 .888-.631 1.612-1.438 1.612"/> </svg></a></li>
<li class="no-hover"><a href="https://x.com/HeyPuter" class="social-link" target="_blank"><svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="currentColor" class="bi bi-twitter-x" viewBox="0 0 16 16"> <path d="M12.6.75h2.454l-5.36 6.142L16 15.25h-4.937l-3.867-5.07-4.425 5.07H.316l5.733-6.57L0 .75h5.063l3.495 4.633L12.601.75Zm-.86 13.028h1.36L4.323 2.145H2.865z"/> </svg></a></li>
<li class="no-hover"><a href="https://reddit.com/r/puter" class="social-link" target="_blank"><svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="currentColor" class="bi bi-reddit" viewBox="0 0 16 16"> <path d="M6.167 8a.83.83 0 0 0-.83.83c0 .459.372.84.83.831a.831.831 0 0 0 0-1.661m1.843 3.647c.315 0 1.403-.038 1.976-.611a.23.23 0 0 0 0-.306.213.213 0 0 0-.306 0c-.353.363-1.126.487-1.67.487-.545 0-1.308-.124-1.671-.487a.213.213 0 0 0-.306 0 .213.213 0 0 0 0 .306c.564.563 1.652.61 1.977.61zm.992-2.807c0 .458.373.83.831.83s.83-.381.83-.83a.831.831 0 0 0-1.66 0z"/> <path d="M16 8A8 8 0 1 1 0 8a8 8 0 0 1 16 0m-3.828-1.165c-.315 0-.602.124-.812.325-.801-.573-1.9-.945-3.121-.993l.534-2.501 1.738.372a.83.83 0 1 0 .83-.869.83.83 0 0 0-.744.468l-1.938-.41a.2.2 0 0 0-.153.028.2.2 0 0 0-.086.134l-.592 2.788c-1.24.038-2.358.41-3.17.992-.21-.2-.496-.324-.81-.324a1.163 1.163 0 0 0-.478 2.224q-.03.17-.029.353c0 1.795 2.091 3.256 4.669 3.256s4.668-1.451 4.668-3.256c0-.114-.01-.238-.029-.353.401-.181.688-.592.688-1.069 0-.65-.525-1.165-1.165-1.165"/> </svg></a></li>
</ul>
</div>
</div>
</section>
<div class="main">
<!---------------------------------------->
<!-- Earn money -->
<!---------------------------------------->
<dialog id="earn-money">
<h3 style="font-size: 30px; margin-top:10px; font-weight: 500;">Developers earn money on Puter!</h3>
<p>Follow the steps below to start earning money on Puter:</p>
<ol>
<li>Publish as many apps as you want on Puter.</li>
<li>We automatically review every app continuously. Qualified apps are automatically added to our
Incentive Program to earn money.</li>
<li>You will earn money every time your approved apps are opened by users.</li>
</ol>
<span class="close-message" id="earn-money-c2a-close" data-target="#earn-money"></span>
<hr>
<span style="font-size:15px;">Questions? Contact us: <a href="mailto:hi@puter.com"
style="outline: none;">hi@puter.com</a></span>
<a style="font-size: 14px; float: right; outline: none;" href="https://puter.com/incentive-program-terms"
target="_blank">Incentive Program Terms</a>
</dialog>
<!---------------------------------------->
<!-- Earn money -->
<!---------------------------------------->
<dialog id="earn-money">
<h3 style="font-size: 30px; margin-top:10px; font-weight: 500;">Developers earn money on Puter!</h3>
<p>Follow the steps below to start earning money on Puter:</p>
<ol>
<li>Publish as many apps as you want on Puter.</li>
<li>We automatically review every app continuously. Qualified apps are automatically added to our Incentive Program to earn money.</li>
<li>You will earn money every time your approved apps are opened by users.</li>
</ol>
<span class="close-message" id="earn-money-c2a-close" data-target="#earn-money"></span>
<hr>
<span style="font-size:15px;">Questions? Contact us: <a href="mailto:hi@puter.com" style="outline: none;">hi@puter.com</a></span>
<a style="font-size: 14px; float: right; outline: none;" href="https://puter.com/incentive-program-terms" target="_blank">Incentive Program Terms</a>
</dialog>
<!---------------------------------------->
<!-- Dev Incentive Program -->
<!---------------------------------------->
<section id="join-incentive-program">
<section id="jip-form">
<h1 style="font-weight: 400;">Great News!</h1>
<p>You are approved to join the Puter Incentive Program: A revolutionary, invite-only program to earn
money every time your apps are opened!</p>
<p>Please use the following form to join the program.</p>
<form style="clear:both;">
<div>
<div class="error" id="jip-error" style="width: 390px;"></div>
<div style="margin-bottom: 10px; overflow:hidden;">
<div style="width: 200px; float:left;">
<label for="jip-first-name">First Name</label>
<input type="text" id="jip-first-name" placeholder="">
</div>
<div style="width: 200px; float:left; margin-left:10px;">
<label for="jip-last-name">Last Name</label>
<input type="text" id="jip-last-name" placeholder="">
</div>
<!---------------------------------------->
<!-- Dev Incentive Program -->
<!---------------------------------------->
<section id="join-incentive-program">
<section id="jip-form">
<h1 style="font-weight: 400;">Great News!</h1>
<p>You are approved to join the Puter Incentive Program: A revolutionary, invite-only program to earn money every time your apps are opened!</p>
<p>Please use the following form to join the program.</p>
<form style="clear:both;">
<div>
<div class="error" id="jip-error" style="width: 390px;"></div>
<div style="margin-bottom: 10px; overflow:hidden;">
<div style="width: 200px; float:left;">
<label for="jip-first-name">First Name</label>
<input type="text" id="jip-first-name" placeholder="">
</div>
<div style="clear: both; margin-top: 20px; width: 410px;">
<label for="jip-paypal">Paypal email address for receiving your payouts</label>
<input type="text" id="jip-paypal">
<div style="width: 200px; float:left; margin-left:10px;">
<label for="jip-last-name">Last Name</label>
<input type="text" id="jip-last-name" placeholder="">
</div>
</div>
<div style="clear: both; margin-top: 20px; width: 410px;">
<label for="jip-paypal">Paypal email address for receiving your payouts</label>
<input type="text" id="jip-paypal">
</div>
</div>
<p class="ip-terms-notice">By clicking Join Now, you agree to our <a
href="https://puter.com/incentive-program-terms" target="_blank">Incentive Program
Terms</a>.</p>
<button type="button" class="jip-submit-btn button button-large button-primary">Join Now</button>
</form>
</section>
<section id="jip-success">
<h1>🎉 Congratulations!</h1>
<p>You have successfully joined the Puter Incentive Program. You will start earning money from your
eligible apps.</p>
<p>Please do not hesitate to contact us at <a href="mailto:hey@puter.com">hey@puter.com</a> should you
have any questions.</p>
<span class="close-message" data-target="#join-incentive-program"></span>
</section>
<p class="ip-terms-notice">By clicking Join Now, you agree to our <a href="https://puter.com/incentive-program-terms" target="_blank">Incentive Program Terms</a>.</p>
<button type="button" class="jip-submit-btn button button-large button-primary">Join Now</button>
</form>
</section>
<!---------------------------------------->
<!-- Payout Method -->
<!---------------------------------------->
<section id="tab-payout-method" style="display:none;">
<h1>Payout Method</h1>
<div style="overflow: hidden;">
<img src="./img/paypal.svg" style="float:left; width: 40px; height: 50px;"><span
id="payout-method-email"></span>
</div>
<p style="font-size:14px; margin-top:20px;"><strong>Please note:</strong> every month, you will receive your
earnings from the previous month. The payment is usually processed within the first seven business days
of the new month. Please do not hesitate to contact us at <a
href="mailto:hey@puter.com">hey@puter.com</a> should you have any questions.</p>
<section id="jip-success">
<h1>🎉 Congratulations!</h1>
<p>You have successfully joined the Puter Incentive Program. You will start earning money from your eligible apps.</p>
<p>Please do not hesitate to contact us at <a href="mailto:hey@puter.com">hey@puter.com</a> should you have any questions.</p>
<span class="close-message" data-target="#join-incentive-program"></span>
</section>
</section>
<!---------------------------------------->
<!-- No Apps Messaage -->
<!---------------------------------------->
<section id="no-apps-notice" style="display:none;">
<img src="./img/apps-black.svg" style="width: 64px; opacity: 0.12;">
<p style="color: #606062;">You haven't created any apps yet.</p>
<button class="create-an-app-btn button button-primary"><img src="./img/plus.svg">Create an App</button>
</section>
<!---------------------------------------->
<!-- Payout Method -->
<!---------------------------------------->
<section id="tab-payout-method" style="display:none;">
<h1>Payout Method</h1>
<div style="overflow: hidden;">
<img src="./img/paypal.svg" style="float:left; width: 40px; height: 50px;"><span id="payout-method-email"></span>
</div>
<p style="font-size:14px; margin-top:20px;"><strong>Please note:</strong> every month, you will receive your earnings from the previous month. The payment is usually processed within the first seven business days of the new month. Please do not hesitate to contact us at <a href="mailto:hey@puter.com">hey@puter.com</a> should you have any questions.</p>
</section>
<!---------------------------------------->
<!-- No Workers Message -->
<!---------------------------------------->
<section id="no-workers-notice" style="display:none;">
<img src="./img/workers-placeholder.svg"
style="width: 64px; height: 64px; opacity: 0.62; filter: grayscale(100%); transform: rotate(-20deg);">
<p style="color: #606062;">You haven't created any workers yet.</p>
<button class="create-a-worker-btn button button-primary"><img src="./img/plus.svg">Create a Worker</button>
</section>
<!---------------------------------------->
<!-- No Apps Messaage -->
<!---------------------------------------->
<section id="no-apps-notice" style="display:none;">
<img src="./img/apps-black.svg" style="width: 64px; opacity: 0.12;">
<p style="color: #606062;">You haven't created any apps yet.</p>
<button class="create-an-app-btn button button-primary"><img src="./img/plus.svg">Create an App</button>
</section>
<!---------------------------------------->
<!-- No Websites Message -->
<!---------------------------------------->
<section id="no-websites-notice" style="display:none;">
<img src="./img/websites-placeholder.svg"
style="width: 64px; height: 64px; opacity: 0.22; filter: grayscale(100%);">
<p style="color: #606062;">You haven't created any websites yet.</p>
<button class="create-a-website-btn button button-primary"><img src="./img/plus.svg">Create a
Website</button>
</section>
<!---------------------------------------->
<!-- Edit App -->
<!---------------------------------------->
<section id="edit-app" style="margin-bottom: 100px;">
</section>
<!---------------------------------------->
<!-- No Workers Message -->
<!---------------------------------------->
<section id="no-workers-notice" style="display:none;">
<img src="./img/workers-placeholder.svg" style="width: 64px; height: 64px; opacity: 0.62; filter: grayscale(100%); transform: rotate(-20deg);">
<p style="color: #606062;">You haven't created any workers yet.</p>
<button class="create-a-worker-btn button button-primary"><img src="./img/plus.svg">Create a Worker</button>
</section>
<!---------------------------------------->
<!-- Insta-Deploy Modal -->
<!---------------------------------------->
<dialog class="insta-deploy-modal">
<p>Deploy <strong class="insta-deploy-item-name"></strong> to:</p>
<div style="overflow: hidden;">
<div class="insta-deploy-to-new-app">New App</div>
<div class="insta-deploy-to-existing-app">An Existing App</div>
</div>
<span class="insta-deploy-cancel">Cancel</span>
</dialog>
<!---------------------------------------->
<!-- No Websites Message -->
<!---------------------------------------->
<section id="no-websites-notice" style="display:none;">
<img src="./img/websites-placeholder.svg" style="width: 64px; height: 64px; opacity: 0.22; filter: grayscale(100%);">
<p style="color: #606062;">You haven't created any websites yet.</p>
<button class="create-a-website-btn button button-primary"><img src="./img/plus.svg">Create a Website</button>
</section>
<!---------------------------------------->
<!-- Edit App -->
<!---------------------------------------->
<section id="edit-app" style="margin-bottom: 100px;">
</section>
<dialog class="insta-deploy-existing-app-select">
<span class="insta-deploy-existing-app-back">Back</span>
<p>Select app to deploy to:</p>
<div class="insta-deploy-existing-app-list"></div>
<button style="margin-top: 10px;"
class="button button-primary button-block disabled insta-deploy-existing-app-deploy-btn">Deploy</button>
<div class="insta-deploy-cancel">Cancel</div>
</dialog>
<!---------------------------------------->
<!-- Insta-Deploy Modal -->
<!---------------------------------------->
<dialog class="insta-deploy-modal">
<p>Deploy <strong class="insta-deploy-item-name"></strong> to:</p>
<div style="overflow: hidden;">
<div class="insta-deploy-to-new-app">New App</div>
<div class="insta-deploy-to-existing-app">An Existing App</div>
</div>
<span class="insta-deploy-cancel">Cancel</span>
</dialog>
<!---------------------------------------->
<!-- App List -->
<!---------------------------------------->
<section id="app-list">
<div class="app-list-nav">
<h1 class="my-apps-title">My Apps<span class="app-count"></span></h1>
<button class="setup-account-btn button button-secondary" style="float:right; margin-bottom: 10px;">Open
Payments Dev Account</button>
<button class="create-an-app-btn button button-primary" style="float:right; margin-bottom: 10px;">New
App</button>
</div>
<dialog class="insta-deploy-existing-app-select">
<span class="insta-deploy-existing-app-back">Back</span>
<p>Select app to deploy to:</p>
<div class="insta-deploy-existing-app-list"></div>
<button style="margin-top: 10px;" class="button button-primary button-block disabled insta-deploy-existing-app-deploy-btn">Deploy</button>
<div class="insta-deploy-cancel">Cancel</div>
</dialog>
<div class="search-container">
<input style="background-image:url(./img/magnifier-outline.svg);" class="search search-apps"
placeholder="Search apps">
<img class="search-clear search-clear-apps" src="./img/close.svg">
</div>
<!---------------------------------------->
<!-- App List -->
<!---------------------------------------->
<section id="app-list">
<div class="app-list-nav">
<h1 class="my-apps-title">My Apps<span class="app-count"></span></h1>
<button class="create-an-app-btn button button-primary"><img src="./img/plus.svg" style="width: 25px; height: 25px;">New App</button>
</div>
<button class="button button-danger disabled delete-apps-btn" style="float:right;">Delete</button>
<button class="refresh-app-list" title="Refresh"><svg class="refresh-icon"
xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" x="0px" y="0px"
width="32px" height="32px" viewBox="0 0 32 32" stroke-width="2">
<g stroke-width="2" transform="translate(0.5, 0.5)">
<path data-cap="butt" d="M29.382,9.217A15,15,0,0,0,1,16" fill="none" stroke="#444444"
stroke-miterlimit="10" stroke-width="2" stroke-linecap="butt" stroke-linejoin="miter">
</path>
<polyline points="28.383 1.22 29.383 9.22 21.383 8.22" fill="none" stroke="#444444"
stroke-linecap="square" stroke-miterlimit="10" stroke-width="2" stroke-linejoin="miter">
</polyline>
<path data-cap="butt" data-color="color-2" d="M2.618,22.783A15,15,0,0,0,31,16" fill="none"
stroke="#444444" stroke-miterlimit="10" stroke-width="2" stroke-linecap="butt"
stroke-linejoin="miter"></path>
<polyline data-color="color-2" points="3.617 30.78 2.617 22.78 10.617 23.78" fill="none"
stroke="#444444" stroke-linecap="square" stroke-miterlimit="10" stroke-width="2"
stroke-linejoin="miter"></polyline>
</g>
</svg></button>
<div style="overflow-x: auto; clear: both;">
<table class="table" id="app-list-table">
<thead class="disable-user-select">
<tr>
<th><input type="checkbox" class="select-all-apps"
style="width: 15px; height: 20px; margin-left:3px;"></th>
<th class="sort th-name" data-column="name" style="padding-left: 10px !important;">App<span
class="sort-arrow sort-arrow-desc"></span><span
class="sort-arrow sort-arrow-asc"></span></th>
<th class="sort th-users" data-column="user_count">Users<span
class="sort-arrow sort-arrow-desc"></span><span
class="sort-arrow sort-arrow-asc"></span></th>
<th class="sort th-opens" data-column="open_count">Opens<span
class="sort-arrow sort-arrow-desc"></span><span
class="sort-arrow sort-arrow-asc"></span></th>
<th class="sort th-created sorted" data-column="created_at">Created<span
class="sort-arrow sort-arrow-desc" style="display:inline;"></span><span
class="sort-arrow sort-arrow-asc"></span></th>
<th></th>
<th></th>
</tr>
</thead>
<tbody>
<div class="search-container">
<input style="background-image:url(./img/magnifier-outline.svg);" class="search search-apps" placeholder="Search apps">
<img class="search-clear search-clear-apps" src="./img/close.svg">
</div>
</tbody>
</table>
</div>
</section>
<button class="button button-danger disabled delete-apps-btn" style="float:right;">Delete</button>
<button class="refresh-app-list" title="Refresh"><svg class="refresh-icon" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" x="0px" y="0px" width="32px" height="32px" viewBox="0 0 32 32" stroke-width="2"><g stroke-width="2" transform="translate(0.5, 0.5)"><path data-cap="butt" d="M29.382,9.217A15,15,0,0,0,1,16" fill="none" stroke="#444444" stroke-miterlimit="10" stroke-width="2" stroke-linecap="butt" stroke-linejoin="miter"></path><polyline points="28.383 1.22 29.383 9.22 21.383 8.22" fill="none" stroke="#444444" stroke-linecap="square" stroke-miterlimit="10" stroke-width="2" stroke-linejoin="miter"></polyline><path data-cap="butt" data-color="color-2" d="M2.618,22.783A15,15,0,0,0,31,16" fill="none" stroke="#444444" stroke-miterlimit="10" stroke-width="2" stroke-linecap="butt" stroke-linejoin="miter"></path><polyline data-color="color-2" points="3.617 30.78 2.617 22.78 10.617 23.78" fill="none" stroke="#444444" stroke-linecap="square" stroke-miterlimit="10" stroke-width="2" stroke-linejoin="miter"></polyline></g></svg></button>
<div style="overflow-x: auto; clear: both;">
<table class="table" id="app-list-table">
<thead class="disable-user-select">
<tr>
<th><input type="checkbox" class="select-all-apps" style="width: 15px; height: 20px; margin-left:3px;"></th>
<th class="sort th-name" data-column="name" style="padding-left: 10px !important;">App<span class="sort-arrow sort-arrow-desc"></span><span class="sort-arrow sort-arrow-asc"></span></th>
<th class="sort th-users" data-column="user_count">Users<span class="sort-arrow sort-arrow-desc"></span><span class="sort-arrow sort-arrow-asc"></span></th>
<th class="sort th-opens" data-column="open_count">Opens<span class="sort-arrow sort-arrow-desc"></span><span class="sort-arrow sort-arrow-asc"></span></th>
<th class="sort th-created sorted" data-column="created_at">Created<span class="sort-arrow sort-arrow-desc" style="display:inline;"></span><span class="sort-arrow sort-arrow-asc"></span></th>
<th></th>
<th></th>
</tr>
</thead>
<tbody>
<!---------------------------------------->
<!-- Worker List -->
<!---------------------------------------->
<section id="worker-list">
<div class="worker-list-nav">
<h1 class="my-workers-title">My Workers<span class="worker-count"></span></h1>
<button class="create-a-worker-btn button button-primary"><img src="./img/plus.svg"
style="width: 25px; height: 25px;"> New Worker</button>
</div>
</tbody>
</table>
</div>
</section>
<div class="search-container">
<input style="background-image:url(./img/magnifier-outline.svg);" class="search search-workers"
placeholder="Search workers">
<img class="search-clear search-clear-workers" src="./img/close.svg">
</div>
<!---------------------------------------->
<!-- Worker List -->
<!---------------------------------------->
<section id="worker-list">
<div class="worker-list-nav">
<h1 class="my-workers-title">My Workers<span class="worker-count"></span></h1>
<button class="create-a-worker-btn button button-primary"><img src="./img/plus.svg" style="width: 25px; height: 25px;"> New Worker</button>
</div>
<button class="button button-danger disabled delete-workers-btn" style="float:right;">Delete</button>
<button class="refresh-worker-list" title="Refresh"><svg class="refresh-icon"
xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" x="0px" y="0px"
width="32px" height="32px" viewBox="0 0 32 32" stroke-width="2">
<g stroke-width="2" transform="translate(0.5, 0.5)">
<path data-cap="butt" d="M29.382,9.217A15,15,0,0,0,1,16" fill="none" stroke="#444444"
stroke-miterlimit="10" stroke-width="2" stroke-linecap="butt" stroke-linejoin="miter">
</path>
<polyline points="28.383 1.22 29.383 9.22 21.383 8.22" fill="none" stroke="#444444"
stroke-linecap="square" stroke-miterlimit="10" stroke-width="2" stroke-linejoin="miter">
</polyline>
<path data-cap="butt" data-color="color-2" d="M2.618,22.783A15,15,0,0,0,31,16" fill="none"
stroke="#444444" stroke-miterlimit="10" stroke-width="2" stroke-linecap="butt"
stroke-linejoin="miter"></path>
<polyline data-color="color-2" points="3.617 30.78 2.617 22.78 10.617 23.78" fill="none"
stroke="#444444" stroke-linecap="square" stroke-miterlimit="10" stroke-width="2"
stroke-linejoin="miter"></polyline>
</g>
</svg></button>
<div style="overflow-x: auto; clear: both;">
<table class="table" id="worker-list-table">
<thead class="disable-user-select">
<tr>
<th><input type="checkbox" class="select-all-workers"
style="width: 15px; height: 20px; margin-left:3px;"></th>
<th class="sort th-name" data-column="name" style="padding-left: 10px !important;">
Worker<span class="sort-arrow sort-arrow-desc"></span><span
class="sort-arrow sort-arrow-asc"></span></th>
<th class="sort th-file" data-column="file_path">File<span
class="sort-arrow sort-arrow-desc"></span><span
class="sort-arrow sort-arrow-asc"></span></th>
<th class="sort th-created sorted" data-column="created_at">Created<span
class="sort-arrow sort-arrow-desc" style="display:inline;"></span><span
class="sort-arrow sort-arrow-asc"></span></th>
<th></th>
</tr>
</thead>
<tbody>
<div class="search-container">
<input style="background-image:url(./img/magnifier-outline.svg);" class="search search-workers" placeholder="Search workers">
<img class="search-clear search-clear-workers" src="./img/close.svg">
</div>
</tbody>
</table>
</div>
</section>
<!---------------------------------------->
<!-- Websites List -->
<!---------------------------------------->
<section id="website-list">
<div class="website-list-nav">
<h1 class="my-websites-title">My Websites<span class="website-count"></span></h1>
<button class="create-a-website-btn button button-primary"><img src="./img/plus.svg"
style="width: 25px; height: 25px;">New Website</button>
</div>
<button class="button button-danger disabled delete-workers-btn" style="float:right;">Delete</button>
<button class="refresh-worker-list" title="Refresh"><svg class="refresh-icon" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" x="0px" y="0px" width="32px" height="32px" viewBox="0 0 32 32" stroke-width="2"><g stroke-width="2" transform="translate(0.5, 0.5)"><path data-cap="butt" d="M29.382,9.217A15,15,0,0,0,1,16" fill="none" stroke="#444444" stroke-miterlimit="10" stroke-width="2" stroke-linecap="butt" stroke-linejoin="miter"></path><polyline points="28.383 1.22 29.383 9.22 21.383 8.22" fill="none" stroke="#444444" stroke-linecap="square" stroke-miterlimit="10" stroke-width="2" stroke-linejoin="miter"></polyline><path data-cap="butt" data-color="color-2" d="M2.618,22.783A15,15,0,0,0,31,16" fill="none" stroke="#444444" stroke-miterlimit="10" stroke-width="2" stroke-linecap="butt" stroke-linejoin="miter"></path><polyline data-color="color-2" points="3.617 30.78 2.617 22.78 10.617 23.78" fill="none" stroke="#444444" stroke-linecap="square" stroke-miterlimit="10" stroke-width="2" stroke-linejoin="miter"></polyline></g></svg></button>
<div style="overflow-x: auto; clear: both;">
<table class="table" id="worker-list-table">
<thead class="disable-user-select">
<tr>
<th><input type="checkbox" class="select-all-workers" style="width: 15px; height: 20px; margin-left:3px;"></th>
<th class="sort th-name" data-column="name" style="padding-left: 10px !important;">Worker<span class="sort-arrow sort-arrow-desc"></span><span class="sort-arrow sort-arrow-asc"></span></th>
<th class="sort th-file" data-column="file_path">File<span class="sort-arrow sort-arrow-desc"></span><span class="sort-arrow sort-arrow-asc"></span></th>
<th class="sort th-created sorted" data-column="created_at">Created<span class="sort-arrow sort-arrow-desc" style="display:inline;"></span><span class="sort-arrow sort-arrow-asc"></span></th>
<th></th>
</tr>
</thead>
<tbody>
<div class="search-container">
<input style="background-image:url(./img/magnifier-outline.svg);" class="search search-websites"
placeholder="Search websites">
<img class="search-clear search-clear-websites" src="./img/close.svg">
</div>
</tbody>
</table>
</div>
</section>
<!---------------------------------------->
<!-- Websites List -->
<!---------------------------------------->
<section id="website-list">
<div class="website-list-nav">
<h1 class="my-websites-title">My Websites<span class="website-count"></span></h1>
<button class="create-a-website-btn button button-primary"><img src="./img/plus.svg" style="width: 25px; height: 25px;">New Website</button>
</div>
<button class="button button-danger disabled delete-websites-btn" style="float:right;">Delete</button>
<button class="refresh-website-list" style="width:40px; padding: 10px; float: right; margin-right: 10px;"
title="Refresh"><svg class="refresh-icon" xmlns="http://www.w3.org/2000/svg"
xmlns:xlink="http://www.w3.org/1999/xlink" x="0px" y="0px" width="32px" height="32px"
viewBox="0 0 32 32" stroke-width="2">
<g stroke-width="2" transform="translate(0.5, 0.5)">
<path data-cap="butt" d="M29.382,9.217A15,15,0,0,0,1,16" fill="none" stroke="#444444"
stroke-miterlimit="10" stroke-width="2" stroke-linecap="butt" stroke-linejoin="miter">
</path>
<polyline points="28.383 1.22 29.383 9.22 21.383 8.22" fill="none" stroke="#444444"
stroke-linecap="square" stroke-miterlimit="10" stroke-width="2" stroke-linejoin="miter">
</polyline>
<path data-cap="butt" data-color="color-2" d="M2.618,22.783A15,15,0,0,0,31,16" fill="none"
stroke="#444444" stroke-miterlimit="10" stroke-width="2" stroke-linecap="butt"
stroke-linejoin="miter"></path>
<polyline data-color="color-2" points="3.617 30.78 2.617 22.78 10.617 23.78" fill="none"
stroke="#444444" stroke-linecap="square" stroke-miterlimit="10" stroke-width="2"
stroke-linejoin="miter"></polyline>
</g>
</svg></button>
<div style="overflow-x: auto; clear: both;">
<table class="table" id="website-list-table">
<thead class="disable-user-select">
<tr>
<th><input type="checkbox" class="select-all-websites"
style="width: 15px; height: 20px; margin-left:3px;"></th>
<th class="sort th-name" data-column="name" style="padding-left: 10px !important;">
Website<span class="sort-arrow sort-arrow-desc"></span><span
class="sort-arrow sort-arrow-asc"></span></th>
<th class="sort th-root-dir" data-column="root_dir">Connected Directory<span
class="sort-arrow sort-arrow-desc"></span><span
class="sort-arrow sort-arrow-asc"></span></th>
<th class="sort th-created sorted" data-column="created_at">Created<span
class="sort-arrow sort-arrow-desc" style="display:inline;"></span><span
class="sort-arrow sort-arrow-asc"></span></th>
<th></th>
</tr>
</thead>
<tbody>
<div class="search-container">
<input style="background-image:url(./img/magnifier-outline.svg);" class="search search-websites" placeholder="Search websites">
<img class="search-clear search-clear-websites" src="./img/close.svg">
</div>
</tbody>
</table>
</div>
</section>
<button class="button button-danger disabled delete-websites-btn" style="float:right;">Delete</button>
<button class="refresh-website-list" style="width:40px; padding: 10px; float: right; margin-right: 10px;" title="Refresh"><svg class="refresh-icon" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" x="0px" y="0px" width="32px" height="32px" viewBox="0 0 32 32" stroke-width="2"><g stroke-width="2" transform="translate(0.5, 0.5)"><path data-cap="butt" d="M29.382,9.217A15,15,0,0,0,1,16" fill="none" stroke="#444444" stroke-miterlimit="10" stroke-width="2" stroke-linecap="butt" stroke-linejoin="miter"></path><polyline points="28.383 1.22 29.383 9.22 21.383 8.22" fill="none" stroke="#444444" stroke-linecap="square" stroke-miterlimit="10" stroke-width="2" stroke-linejoin="miter"></polyline><path data-cap="butt" data-color="color-2" d="M2.618,22.783A15,15,0,0,0,31,16" fill="none" stroke="#444444" stroke-miterlimit="10" stroke-width="2" stroke-linecap="butt" stroke-linejoin="miter"></path><polyline data-color="color-2" points="3.617 30.78 2.617 22.78 10.617 23.78" fill="none" stroke="#444444" stroke-linecap="square" stroke-miterlimit="10" stroke-width="2" stroke-linejoin="miter"></polyline></g></svg></button>
<div style="overflow-x: auto; clear: both;">
<table class="table" id="website-list-table">
<thead class="disable-user-select">
<tr>
<th><input type="checkbox" class="select-all-websites" style="width: 15px; height: 20px; margin-left:3px;"></th>
<th class="sort th-name" data-column="name" style="padding-left: 10px !important;">Website<span class="sort-arrow sort-arrow-desc"></span><span class="sort-arrow sort-arrow-asc"></span></th>
<th class="sort th-root-dir" data-column="root_dir">Connected Directory<span class="sort-arrow sort-arrow-desc"></span><span class="sort-arrow sort-arrow-asc"></span></th>
<th class="sort th-created sorted" data-column="created_at">Created<span class="sort-arrow sort-arrow-desc" style="display:inline;"></span><span class="sort-arrow sort-arrow-asc"></span></th>
<th></th>
</tr>
</thead>
<tbody>
</tbody>
</table>
</div>
</section>
</div>
<script src="https://js.puter.com/v2/"></script>

File diff suppressed because it is too large Load Diff

View File

@@ -1,8 +0,0 @@
url: http://puter.localhost:4100/
username: admin
token: eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ0IjoicyIsInYiOiIwLjAuMCIsInUiOiI3Vjlkazlxd1N0R1NjLzdFakYraXN3PT0iLCJ1dSI6ImtNa0NYbTBIU01DS2w5K2lNSE10dXc9PSIsImlhdCI6MTc1Nzc5MTcyOX0.s82vH4IKVQUIFTKW7iHzSOlTlDkqmvEzHj4fPpYDi1w
mountpoints:
- path: /
provider: puterfs
- path: /admin/tmp
provider: memoryfs

View File

@@ -1,6 +1,6 @@
{
"compilerOptions": {
"target": "ES2024",
"target": "ES2022",
"module": "node16",
"moduleResolution": "node16",
"rootDir": ".",
@@ -8,7 +8,7 @@
"esModuleInterop": true,
"forceConsistentCasingInFileNames": true,
"skipLibCheck": true,
"sourceMap": true,
"sourceMap": true
},
"exclude": [
"**/*.test.ts",
@@ -17,6 +17,5 @@
"**/tests/**",
"node_modules",
"dist",
"volatile"
]
}