Merge branch 'main' into #141

This commit is contained in:
Nariman Jelveh 2024-04-01 11:02:37 -07:00 committed by GitHub
commit 4009f4f331
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
14 changed files with 442 additions and 111 deletions

View File

@ -24,7 +24,7 @@ USER node
RUN npm cache clean --force \
&& npm install
EXPOSE 4000
EXPOSE 4100
CMD [ "npm", "start" ]

View File

@ -52,9 +52,38 @@ docker compose up
<br/>
See [Configuration](#configuration) for next steps.
<br/>
## ⚠️ Self-Hosting ⚠️
The self-hosted version of Puter is currently in alpha stage and should not be used in production yet. It is under active development and may contain bugs, other issues. Please exercise caution and use it for testing and evaluation purposes only.
## Configuration
Running the server will generate a configuration file at `volatile/config/config.json`.
### Domain Name
To access Puter on your device, you can simply go to the address printed in
the server console (usually `puter.localhost:4100`).
To access Puter from another device, a domain name must be configured, as well as
an `api` subdomain. For example, `example.local` might be the domain name pointing
to the IP address of the server running puter, and `api.example.com` must point to
this address as well. This domain must be specified in the configuration file
(usually `volatile/config/config.json`) as well.
See [domain configuration](./doc/self-hosters/domains.md) for more information.
### Default User
By default, Puter will create a user called `default_user` with the password
`changeme`. A warning will persist in the dev console until this user's
password is changed. Please login to this user and change the password as
your first step. This user by default has 10GB storage instead of the default
(500MB storage) for new/temporary users.
<br/>
## FAQ

View File

@ -0,0 +1,72 @@
# Configurating Domains for Self-Hosted Puter
## Local Network Configuration
### Prerequisite Conditions
Ensure the hosting device has a static IP address to prevent potential connectivity issues due to IP changes. This setup will enable seamless access to Puter and its services across your local network.
### Using Hosts Files
The hosts file is a straightforward way to map domain names to IP addresses on individual devices. It's simple to set up but requires manual changes on each device that needs access to the domains.
#### Windows
1. Open Notepad as an administrator.
2. Open the file located at `C:\Windows\System32\drivers\etc\hosts`.
3. Add lines for your domain and subdomain with the server's IP address, in the
following format:
```
192.168.1.10 puter.local
192.168.1.10 api.puter.local
```
### For macOS and Linux:
1. Open a terminal.
2. Edit the hosts file with a text editor, e.g., `sudo nano /etc/hosts`.
3. Add lines for your domain and subdomain with the server's IP address, in the
following format:
```
192.168.1.10 puter.local
192.168.1.10 api.puter.local
```
4. Save and exit the editor.
### Using Router Configuration
Some routers allow you to add custom DNS rules, letting you configure domain names network-wide without touching each device.
1. Access your routers admin interface (usually through a web browser).
2. Look for DNS or DHCP settings.
3. Add custom DNS mappings for `puter.local` and `api.puter.local` to the hosting device's IP address.
4. Save the changes and reboot the router if necessary.
This method's availability and steps may vary depending on your router's model and firmware.
### Using Local DNS
Setting up a local DNS server on your network allows for flexible and scalable domain name resolution. This method works across all devices automatically once they're configured to use the DNS server.
#### Options for DNS Software:
- **Pi-hole**: Acts as both an ad-blocker and a DNS server. Ideal for easy setup and maintenance.
- **BIND9**: Offers comprehensive DNS server capabilities for complex setups.
- **Dnsmasq**: Lightweight and suitable for smaller networks or those new to running a DNS server.
**contributors note:** feel free to add any software you're aware of
which might help with this to the list. Also, feel free to add instructions here for specific software; our goal is for Puter to be easy to setup with tools you're already familiar with.
#### General Steps:
1. Choose and install DNS server software on a device within your network.
2. Configure the DNS server to resolve `puter.local` and `api.puter.local` to the IP address of your Puter hosting device.
3. Update your routers DHCP settings to distribute the DNS server's IP address to all devices on the network.
By setting up a local DNS server, you gain the most flexibility and control over your network's domain name resolution, ensuring that all devices can access Puter and its API without manual configuration.
## Production Configuration
Please note the self-hosting feature is still in alpha and a public production
deployment is not recommended at this time. However, if you wish to host
publically you can do so following the same steps you normally would to configure
a domain name and ensuring the `api` subdomain points to the server as well.

View File

@ -4,4 +4,4 @@ services:
app:
build: ./
ports:
- 4000:4000
- 4100:4100

40
package-lock.json generated
View File

@ -9637,6 +9637,45 @@
"resolved": "https://registry.npmjs.org/string-hash/-/string-hash-1.1.3.tgz",
"integrity": "sha512-kJUvRUFK49aub+a7T1nNE66EJbZBMnBgoC1UbCZ5n6bsZKBRga4KgBRTMn/pFkeCZSYtNeSyMxPDM0AXWELk2A=="
},
"node_modules/string-length": {
"version": "6.0.0",
"resolved": "https://registry.npmjs.org/string-length/-/string-length-6.0.0.tgz",
"integrity": "sha512-1U361pxZHEQ+FeSjzqRpV+cu2vTzYeWeafXFLykiFlv4Vc0n3njgU8HrMbyik5uwm77naWMuVG8fhEF+Ovb1Kg==",
"dependencies": {
"strip-ansi": "^7.1.0"
},
"engines": {
"node": ">=16"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/string-length/node_modules/ansi-regex": {
"version": "6.0.1",
"resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.0.1.tgz",
"integrity": "sha512-n5M855fKb2SsfMIiFFoVrABHJC8QtHwVx+mHWP3QcEqBHYienj5dHSgjbxtC0WEZXYt4wcD6zrQElDPhFuZgfA==",
"engines": {
"node": ">=12"
},
"funding": {
"url": "https://github.com/chalk/ansi-regex?sponsor=1"
}
},
"node_modules/string-length/node_modules/strip-ansi": {
"version": "7.1.0",
"resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.1.0.tgz",
"integrity": "sha512-iq6eVVI64nQQTRYq2KtEg2d2uU7LElhTJwsH4YzIHZshxlgZms/wIc4VoDQTlG/IvVIrBKG06CrZnp0qv7hkcQ==",
"dependencies": {
"ansi-regex": "^6.0.1"
},
"engines": {
"node": ">=12"
},
"funding": {
"url": "https://github.com/chalk/strip-ansi?sponsor=1"
}
},
"node_modules/string-width": {
"version": "4.2.3",
"resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz",
@ -10807,6 +10846,7 @@
"socket.io": "^4.6.2",
"ssh2": "^1.13.0",
"string-hash": "^1.1.3",
"string-length": "^6.0.0",
"svgo": "^3.0.2",
"tiktoken": "^1.0.11",
"uglify-js": "^3.17.4",

View File

@ -61,6 +61,7 @@
"socket.io": "^4.6.2",
"ssh2": "^1.13.0",
"string-hash": "^1.1.3",
"string-length": "^6.0.0",
"svgo": "^3.0.2",
"tiktoken": "^1.0.11",
"uglify-js": "^3.17.4",

View File

@ -182,6 +182,9 @@ const install = async ({ services, app }) => {
const { EventService } = require('./services/EventService');
services.registerService('event', EventService);
const DefaultUserService = require('./services/DefaultUserService');
services.registerService('__default-user', DefaultUserService);
}
const install_legacy = async ({ services }) => {

View File

@ -95,11 +95,8 @@ class Kernel extends AdvancedBase {
root_context.arun(async () => {
await this._install_modules();
});
(async () => {
await this._boot_services();
})();
});
// Error.stackTraceLimit = Infinity;
@ -183,6 +180,7 @@ class Kernel extends AdvancedBase {
await services.emit('start.webserver');
await services.emit('ready.webserver');
}
}

View File

@ -0,0 +1,32 @@
const { TeePromise } = require('../util/promise');
const es_import_promise = new TeePromise();
let stringLength;
(async () => {
stringLength = (await import('string-length')).default;
es_import_promise.resolve();
// console.log('STRING LENGTH', stringLength);
// process.exit(0);
})();
const surrounding_box = (col, lines, lengths) => {
if ( ! lengths ) {
lengths = lines.map(line => stringLength(line));
}
const max_length = Math.max(...lengths);
const c = str => `\x1b[${col}m${str}\x1b[0m`;
const bar = c(Array(max_length + 4).fill('━').join(''));
for ( let i = 0 ; i < lines.length ; i++ ) {
while ( stringLength(lines[i]) < max_length ) {
lines[i] += ' ';
}
lines[i] = `${c('┃ ')} ${lines[i]} ${c(' ┃')}`;
}
lines.unshift(`${c('┏')}${bar}${c('┓')}`);
lines.push(`${c('┗')}${bar}${c('┛')}`);
};
module.exports = {
surrounding_box,
es_import_promise,
};

View File

@ -0,0 +1,124 @@
const { surrounding_box } = require("../fun/dev-console-ui-utils");
const { get_user, generate_system_fsentries, invalidate_cached_user } = require("../helpers");
const { Context } = require("../util/context");
const { asyncSafeSetInterval } = require("../util/promise");
const BaseService = require("./BaseService");
const { Actor, UserActorType } = require("./auth/Actor");
const { DB_WRITE } = require("./database/consts");
const DEFAULT_PASSWORD = 'changeme';
const USERNAME = 'default_user';
class DefaultUserService extends BaseService {
static MODULES = {
bcrypt: require('bcrypt'),
uuidv4: require('uuid').v4,
}
async _init () {
}
async ['__on_ready.webserver'] () {
// check if a user named `default-user` exists
let user = await get_user({ username: USERNAME, cached: false });
if ( ! user ) user = await this.create_default_user_();
// check if user named `default-user` is using default password
const require = this.require;
const tmp_password = await this.get_tmp_password_(user);
console.log(`second input [${tmp_password}]`);
const bcrypt = require('bcrypt');
console.log(...[
'THESE ARE THE ARGS',
tmp_password,
// password_hashed,
user.password
].map(l => l + '\n'));
const is_default_password = await bcrypt.compare(
tmp_password,
user.password
);
if ( ! is_default_password ) return;
// show console widget
this.default_user_widget = () => {
const lines = [
`Your default user has been created!`,
`\x1B[31;1musername:\x1B[0m ${USERNAME}`,
`\x1B[32;1mpassword:\x1B[0m ${tmp_password}`,
`(change the password to remove this message)`
];
surrounding_box('31;1', lines);
return lines;
};
this.start_poll_({ tmp_password, user });
const svc_devConsole = this.services.get('dev-console');
svc_devConsole.add_widget(this.default_user_widget);
}
start_poll_ ({ tmp_password, user }) {
const interval = 1000 * 3; // 3 seconds
const poll_interval = asyncSafeSetInterval(async () => {
const user = await get_user({ username: USERNAME });
const require = this.require;
const bcrypt = require('bcrypt');
const is_default_password = await bcrypt.compare(
tmp_password,
user.password
);
if ( ! is_default_password ) {
const svc_devConsole = this.services.get('dev-console');
svc_devConsole.remove_widget(this.default_user_widget);
clearInterval(poll_interval);
return;
}
}, interval);
}
async create_default_user_ () {
const db = this.services.get('database').get(DB_WRITE, 'default-user');
await db.write(
`
INSERT INTO user (uuid, username, free_storage)
VALUES (?, ?, ?)
`,
[
this.modules.uuidv4(),
USERNAME,
1024 * 1024 * 1024 * 10, // 10 GB
],
);
const user = await get_user({ username: USERNAME });
const tmp_password = await this.get_tmp_password_(user);
console.log(`first input [${tmp_password}]`);
const bcrypt = require('bcrypt');
const password_hashed = await bcrypt.hash(tmp_password, 8);
await db.write(
`UPDATE user SET password = ? WHERE id = ?`,
[
password_hashed,
user.id,
],
);
user.password = password_hashed;
await generate_system_fsentries(user);
invalidate_cached_user(user);
await new Promise(rslv => setTimeout(rslv, 2000));
return user;
}
async get_tmp_password_ (user) {
const actor = await Actor.create(UserActorType, { user });
return await Context.get().sub({ actor }).arun(async () => {
const svc_driver = this.services.get('driver');
const driver_response = await svc_driver.call(
'puter-kvstore', 'get', { key: 'tmp_password' });
if ( driver_response.result ) return driver_response.result.value;
const tmp_password = require('crypto').randomBytes(4).toString('hex');
await svc_driver.call(
'puter-kvstore', 'set', {
key: 'tmp_password',
value: tmp_password });
return tmp_password;
});
}
}
module.exports = DefaultUserService;

View File

@ -27,6 +27,7 @@ var http = require('http');
const fs = require('fs');
const auth = require('../middleware/auth');
const { osclink } = require('../util/strutil');
const { surrounding_box, es_import_promise } = require('../fun/dev-console-ui-utils');
class WebServerService extends BaseService {
static MODULES = {
@ -42,6 +43,7 @@ class WebServerService extends BaseService {
};
async ['__on_start.webserver'] () {
await es_import_promise;
// error handling middleware goes last, as per the
// expressjs documentation:
@ -122,17 +124,7 @@ class WebServerService extends BaseService {
lines[2].length,
0,
];
const max_length = Math.max(...lengths);
const c = str => `\x1b[34;1m${str}\x1b[0m`;
const bar = c(Array(max_length + 4).fill('━').join(''));
for ( let i = 0 ; i < lines.length ; i++ ) {
while ( lines[i].length < max_length ) {
lines[i] += ' ';
}
lines[i] = `${c('┃ ')} ${lines[i]} ${c(' ┃')}`;
}
lines.unshift(`${c('┏')}${bar}${c('┓')}`);
lines.push(`${c('┗')}${bar}${c('┛')}`);
surrounding_box('34;1', lines, lengths);
return lines;
};
{
@ -360,7 +352,7 @@ class WebServerService extends BaseService {
const pad = (width - last_logo.sz) / 2;
const asymmetrical = pad % 1 !== 0;
const pad_left = Math.floor(pad);
const pad_right = Math.ceil(pad) + (asymmetrical ? 1 : 0);
const pad_right = Math.ceil(pad);
for ( let i = 0 ; i < lines.length ; i++ ) {
lines[i] = ' '.repeat(pad_left) + lines[i] + ' '.repeat(pad_right);
}

View File

@ -84,15 +84,28 @@ class DBKVStore extends BaseImplementation {
const db = this.services.get('database').get(DB_WRITE, 'kvstore');
const key_hash = this.modules.murmurhash.v3(key);
await db.write(
`INSERT INTO kv
(user_id, app, kkey_hash, kkey, value)
VALUES
(?, ?, ?, ?, ?)
ON DUPLICATE KEY UPDATE
value = ?`,
[ user.id, app?.uid ?? 'global', key_hash, key, value, value ]
);
try {
await db.write(
`INSERT INTO kv (user_id, app, kkey_hash, kkey, value)
VALUES (?, ?, ?, ?, ?) ` +
db.case({
mysql: 'ON DUPLICATE KEY UPDATE value = ?',
sqlite: ' ',
// sqlite: 'ON CONFLICT(user_id, app, kkey_hash) DO UPDATE SET value = ?',
}),
[
user.id, app?.uid ?? 'global', key_hash, key, value,
...db.case({ mysql: [value], otherwise: [] }),
]
);
} catch (e) {
// if ( e.code !== 'SQLITE_ERROR' && e.code !== 'SQLITE_CONSTRAINT_PRIMARYKEY' ) throw e;
// The "ON CONFLICT" clause isn't currently working.
await db.write(
`UPDATE kv SET value = ? WHERE user_id=? AND app=? AND kkey_hash=?`,
[ value, user.id, app?.uid ?? 'global', key_hash ]
);
}
return true;
},

View File

@ -34,18 +34,41 @@ class MonthlyUsageService extends BaseService {
const maybe_app_id = actor.type.app?.id;
if ( this.db.case({ sqlite: true, otherwise: false }) ) {
return;
}
// UPSERT increment count
await this.db.write(
'INSERT INTO `service_usage_monthly` (`year`, `month`, `key`, `count`, `user_id`, `app_id`, `extra`) ' +
'VALUES (?, ?, ?, 1, ?, ?, ?) ' +
'ON DUPLICATE KEY UPDATE `count` = `count` + 1',
[
year, month, key,
actor.type.user?.id || null,
maybe_app_id || null,
JSON.stringify(extra)
]
);
try {
await this.db.write(
'INSERT INTO `service_usage_monthly` (`year`, `month`, `key`, `count`, `user_id`, `app_id`, `extra`) ' +
'VALUES (?, ?, ?, 1, ?, ?, ?) ' +
this.db.case({
mysql: 'ON DUPLICATE KEY UPDATE `count` = `count` + 1, `extra` = ?',
sqlite: ' ',
// sqlite: 'ON CONFLICT(`year`, `month`, `key`, `user_id`, `app_id`) ' +
// 'DO UPDATE SET `count` = `count` + 1 AND `extra` = ?',
}),
[
year, month, key, actor.type.user.id, maybe_app_id, JSON.stringify(extra),
...this.db.case({ mysql: [JSON.stringify(extra)], otherwise: [] }),
]
);
} catch (e) {
// if ( e.code !== 'SQLITE_ERROR' && e.code !== 'SQLITE_CONSTRAINT_PRIMARYKEY' ) throw e;
// The "ON CONFLICT" clause isn't currently working.
await this.db.write(
'UPDATE `service_usage_monthly` ' +
'SET `count` = `count` + 1, `extra` = ? ' +
'WHERE `year` = ? AND `month` = ? AND `key` = ? ' +
'AND `user_id` = ? AND `app_id` = ?',
[
JSON.stringify(extra),
year, month, key, actor.type.user.id, maybe_app_id,
]
);
}
}
async check (actor, specifiers) {

View File

@ -23,183 +23,187 @@ const pt = {
dictionary: {
about: "Sobre",
account: "Conta",
access_granted_to: "Acesso garantido a",
add_existing_account: "Incluir Conta Existente",
access_granted_to: "Acesso Dado A",
add_existing_account: "Adicionar Conta Existente",
all_fields_required: 'Todos os campos são obrigatórios.',
apply: "Aplicar",
ascending: 'Ascendente',
auto_arrange: 'Auto Organizar',
background: "Fundo",
browse: "Pesquisar",
browse: "Explorar",
cancel: 'Cancelar',
center: 'Centrar',
change_desktop_background: 'Alterar o fundo…',
change_language: "Alterar a Língua",
change_password: "Alterar a Palavra Passe",
change_username: "Alterar Nome de Utilizador",
change_password: "Alterar a Password",
change_username: "Alterar o Nome de Utilizador",
close_all_windows: "Fechar Todas as Janelas",
close_all_windows_and_log_out: 'Fechar Janelas e Sair',
change_allways_open_with: "Quer sempre abrir ficheiros deste tipo com",
change_allways_open_with: "Queres que ficheiros deste tipo abram sempre com",
color: 'Cor',
confirm_account_for_free_referral_storage_c2a: 'Cries uma conta e confirme o endereço do email para receber 1 GB de armazenamento gratuito. Vosso amigo receberá 1 GB de armazenamento gratuito também.',
confirm_delete_multiple_items: 'Queres apagar estes itens permanentemente?',
confirm_account_for_free_referral_storage_c2a: 'Cria uma conta e confirma o endereço do email para receber 1 GB de armazenamento gratuito. O teu amigo também receberá 1 GB de armazenamento gratuito.',
confirm_delete_multiple_items: 'Tens a certeza que queres apagar estes itens permanentemente?',
confirm_delete_single_item: 'Queres apagar este item permanentemente?',
confirm_open_apps_log_out: 'Possui aplicações abertas. Queres mesmo fechar vossa sessão?',
confirm_new_password: "Confirme vossa Nova Palavra Passe",
confirm_delete_user: "Queres excluir vossa conta? Todos os ficheiros e informações serão destruídas permanentemente. Esta operação não pode ser desfeita.",
contact_us: "Contacte-nos",
confirm_open_apps_log_out: 'Tens aplicações abertas. Queres mesmo terminar a sessão?',
confirm_new_password: "Confirma a Nova Password",
confirm_delete_user: "Tens a certeza que queres apagar a tua conta? Todos os ficheiros e dados serão apagados permanentemente. Esta operação é final.",
contact_us: "Contacta-nos",
contain: 'Contém',
continue: "Continua",
copy: 'Copia',
copy_link: "Copia Link",
copying: "Copiando",
copying: "A copiar",
cover: 'Capa',
create_account: "Criar Conta",
create_free_account: "Criar Conta Gratuita",
create_shortcut: "Criar Atalho",
credits: "Créditos",
current_password: "Palavra Passe Atual",
current_password: "Password Atual",
cut: 'Cortar',
clock: 'Relógio',
clock_visible_hide: 'Esconder - Sempre escondido',
clock_visible_show: 'Mostrar - Sempre visível',
clock_visible_auto: 'Auto - Por defeito, mostra apenas em modo full-screen',
date_modified: 'Data alterada',
delete: 'Excluir',
delete_account: "Excluir Conta",
delete_permanently: "Excluir Permanentemente",
delete: 'Apagar',
delete_account: "Apagar Conta",
delete_permanently: "Apagar Permanentemente",
deploy_as_app: 'Publicar como aplicativo',
descending: 'Descendente',
desktop_background_fit: "Caber",
developers: "Desenvolvedores",
dir_published_as_website: `%strong% foi publicado para:`,
desktop_background_fit: "Ajustado",
developers: "Developers",
dir_published_as_website: `%strong% foi publicado em:`,
disassociate_dir: "Desassociar Diretório",
download: 'Descarregar',
download_file: 'Descarregar Ficheiro',
downloading: "Efectuando a Descarga",
downloading: "Fazendo a descarga",
email: "Email",
email_or_username: "Email ou Nome de Utilizador",
empty_trash: 'Esvaziar Lixo',
empty_trash_confirmation: `Queres apagar os itens do Lixo permanentemente?`,
emptying_trash: 'Deitando o Lixo fora…',
enter_password_to_confirm_delete_user: "Entre vossa palavra passe para confirmar a exclusão da conta",
enter_password_to_confirm_delete_user: "Insere a Password para confirmar a remoção da conta",
feedback: "Feedback",
feedback_c2a: "Favor usares o formulário abaixo para enviar vossos comentários e comunicados.",
feedback_sent_confirmation: "Obrigado por contactar-nos. Se tiveres email associado a esta conta, esperamos ver-nos novamente em breve.",
feedback_c2a: "Pff usa o formulário abaixo para enviar feedback, comentários e bugs.",
feedback_sent_confirmation: "Obrigado por nos contactares. Se tiveres um email associado a esta conta, receberás notícias o mais brevemente que nos seja possível.",
forgot_pass_c2a: "Esqueceste a senha?",
from: "De",
general: "Geral",
get_a_copy_of_on_puter: `Obter uma cópia de '%%' no Puter.com!`,
get_a_copy_of_on_puter: `Obter uma cópia de '%%' em Puter.com!`,
get_copy_link: 'Copiar Link',
hide_all_windows: "Ocultar Todas as Janelas",
html_document: 'Documento HTML',
image: 'Imagem',
invite_link: "Link do Convite",
item: 'item',
items_in_trash_cannot_be_renamed: `Item não pode ser renomeado porque está no lixo. Para renomear, arraste-o para fora do Lixo.`,
items_in_trash_cannot_be_renamed: `Este item não pode ser renomeado porque está no lixo. Para alterar o nome, primeiro arrasta-o para fora do Lixo.`,
jpeg_image: 'Imagem JPEG',
keep_in_taskbar: 'Armazenar na Barra de Tarefas',
keep_in_taskbar: 'Manter na Barra de Tarefas',
language: "Língua",
license: "Licença",
loading: 'Carregando',
log_in: "Entrar",
log_into_another_account_anyway: 'Entrar com outra conta de qualquer maneira',
log_into_another_account_anyway: 'Entrar com outra conta na mesma',
log_out: 'Sair',
move: 'Mover',
moving: "Movendo",
my_websites: "Meus Sites",
name: 'Nome',
name_cannot_be_empty: 'Nome não pode ser vazio.',
name_cannot_contain_double_period: "Nome não pode conter o caracters '..'.",
name_cannot_contain_period: "Nome não pode conter o caracter '.'.",
name_cannot_contain_slash: "Nome não pode conter o caracter '/'.",
name_cannot_contain_double_period: "Nome não pode conter o caractere '..'.",
name_cannot_contain_period: "Nome não pode conter o caractere '.'.",
name_cannot_contain_slash: "Nome não pode conter o caractere '/'.",
name_must_be_string: "Nome tem que ser apenas texto.",
name_too_long: `Nome não pode ter mais que %% characters.`,
name_too_long: `Nome não pode ter mais que %% caracteres.`,
new: 'Novo',
new_folder: 'Nova Pasta',
new_password: "Nova Senha",
new_username: "Novo Utilizador",
new_password: "Nova Password",
new_username: "Novo Nome de Utilizador",
no: 'Não',
no_dir_associated_with_site: 'Não existe diretório associado com este endereço.',
no_websites_published: "Ainda não publicaste sites.",
no_websites_published: "Ainda não tens sites publicados.",
ok: 'OK',
open: "Abrir",
open_in_new_tab: "Abrir em Nova Aba",
open_in_new_window: "Abrir em Nova Janela",
open_with: "Abrir Com",
oss_code_and_content: "Software de Código Aberto",
password: "Palavra Passe",
password_changed: "Palavra Passe alterada.",
passwords_do_not_match: '`Nova Palavra Passe` e `Confirmação de Nova Palavra Passe` não conferem como idênticas.',
password: "Password",
password_changed: "Password alterada.",
passwords_do_not_match: '`Nova Password` e `Confirmação de Nova Password` são diferentes.',
paste: 'Colar',
paste_into_folder: "Cole na Pasta",
paste_into_folder: "Cola na Pasta",
pick_name_for_website: "Escolha um nome para seu site:",
picture: "Imagem",
plural_suffix: 's',
powered_by_puter_js: `Criado por {{link=docs}}Puter.js{{/link}}`,
powered_by_puter_js: `Criado com {{link=docs}}Puter.js{{/link}}`,
preparing: "A preparar...",
preparing_for_upload: "A preparar o envio...",
preparing_for_upload: "A preparar o upload...",
privacy: "Privacidade",
proceed_to_login: 'Proceguir para a entrada',
proceed_with_account_deletion: "Prosseguir com Exclusão da Conta",
proceed_to_login: 'Prosseguir para o login',
proceed_with_account_deletion: "Prosseguir com Remoção da Conta",
properties: "Propriedades",
publish: "Publicar",
publish_as_website: 'Publicar como Site',
puter_description: `Puter é uma nuvem pessoal que prioriza a privacidade para manter todos os seus ficheiros, aplicativos e jogos em um local seguro, acessível de qualquer lugar e a qualquer hora.`,
puter_description: `Puter é uma nuvem pessoal que prioriza a privacidade e que mantém todos os teus ficheiros, aplicativos e jogos num local seguro, acessível de qualquer lugar e a qualquer hora.`,
recent: "Recentes",
recover_password: "Recuperar Senha",
refer_friends_c2a: "Obtenhas 1 GB para cada amigo que criar e confirmar uma conta no Puter. Vosso amigo ganhará 1 GB também!",
refer_friends_social_media_c2a: `Obtenhas 1 GB de armazenamento gratuito no Puter.com!`,
recover_password: "Recuperar Password",
refer_friends_c2a: "Ganha 1 GB por cada amigo que criar e confirmar uma conta Puter. Os teus amigos também ganham 1 GB!",
refer_friends_social_media_c2a: `Ganha 1 GB de armazenamento gratuito em Puter.com!`,
refresh: 'Atualizar',
release_address_confirmation: `Desejas liberar este endereço?`,
release_address_confirmation: `Queres libertar este endereço?`,
remove_from_taskbar:'Remover da Barra de Tarefas',
rename: 'Renomear',
repeat: 'Repetir',
replace: 'Substituir',
replace_all: 'Substituir Todas',
replace_all: 'Substituir Todos',
resend_confirmation_code: "Re-enviar o Código de Confirmação",
restore: "Restaurar",
save_account: 'Gravar conta',
save_account_to_get_copy_link: "Favor criares uma conta para prosseguir.",
save_account_to_publish: 'Favor criares uma conta para prosseguir.',
save_account_to_get_copy_link: "Para continuar, pff cria uma conta.",
save_account_to_publish: 'Para continuar, pff cria uma conta.',
save_session: 'Gravar sessão',
save_session_c2a: 'Crie uma conta para gravares a sessão atual e evitar a perda de vosso trabalho.',
scan_qr_c2a: 'Escaneie o código abaixo para entrares nesta sessão com outros dispositivos',
save_session_c2a: 'Cria uma conta para gravares a sessão atual e evitares perder o teu trabalho.',
scan_qr_c2a: 'Digitaliza o código abaixo para entrares nesta sessão com outros dispositivos',
select: "Selecionar",
selected: 'selecionado',
select_color: 'Selecionar cor…',
send: "Enviar",
send_password_recovery_email: "Enviar Email de Recuperação de Senha",
send_password_recovery_email: "Enviar Email de Recuperação de Password",
session_saved: "Obrigado por criares uma conta. Esta sessão foi gravada.",
settings: "Configurações",
set_new_password: "Informar Palavra Passe",
settings: "Definições",
set_new_password: "Definir nova Password",
share_to: "Partilhar com",
show_all_windows: "Exibir Todas as Janelas",
show_hidden: 'Exibir oculto',
sign_in_with_puter: "Entrar no Puter",
show_all_windows: "Mostrar Todas as Janelas",
show_hidden: 'Exibir janelas ocultas',
sign_in_with_puter: "Entrar em Puter",
sign_up: "Registar",
signing_in: "Entrar…",
size: 'Tamanho',
skip: 'Pular',
sort_by: 'Organizar por',
start: 'Início',
skip: 'Passar à frente',
sort_by: 'Ordenar por',
start: 'Iniciar',
status: "Status",
storage_usage: "Uso do Armazenamento",
taking_longer_than_usual: 'Está a levar mais tempo que o usual. Por favor, aguarde...',
taking_longer_than_usual: 'Está a levar mais tempo que o usual. Por favor aguarda...',
terms: "Termos",
text_document: 'Documento de Texto',
tos_fineprint: `Ao clicar em 'Criar Conta Gratuita' concordas com os {{link=terms}}Termos de Serviço{{/link}} e {{link=privacy}}Política de Privacidade{{/link}} do Puter.`,
tos_fineprint: `Ao clicares em 'Criar Conta Gratuita' concordas com os {{link=terms}}Termos de Serviço{{/link}} e {{link=privacy}}Política de Privacidade{{/link}} do Puter.`,
trash: 'Lixo',
type: 'Tipo',
type_confirm_to_delete_account: "Digite 'confirm' para excluires vossa conta.",
undo: 'Desfazer',
type_confirm_to_delete_account: "Escreve 'confirm' para apagares esta conta.",
undo: 'Voltar atrás',
unlimited: 'Ilimitado',
unzip: "Deszipar",
upload: 'Enviar',
upload_here: 'Enviar aqui',
usage: 'Uso',
unzip: "Abrir zip",
upload: 'Carregar',
upload_here: 'Carregar para aqui',
usage: 'Utilização',
username: "Nome de Utilizador",
username_changed: 'Nome de Utilizador atualizado com sucesso.',
username_changed: 'Nome de Utilizador atualizado.',
versions: "Versões",
yes: 'Sim',
yes_release_it: 'Sim, Libere Isto',
you_have_been_referred_to_puter_by_a_friend: "Indicaste o Puter a um amigo!",
yes_release_it: 'Sim, libertar',
you_have_been_referred_to_puter_by_a_friend: "Um amigo teu recomendou-te a Puter.com!",
zip: "Zipar",
}
};